This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 5fcf30f319a [fix](paimon) Authenticate deferred metadata reads and
normalized HDFS storage (#69247)
5fcf30f319a is described below
commit 5fcf30f319a24068c05dc6977a2f2160af7ef50e
Author: Gabriel <[email protected]>
AuthorDate: Tue Oct 20 09:38:26 2026 +0800
[fix](paimon) Authenticate deferred metadata reads and normalized HDFS
storage (#69247)
### What problem does this PR solve?
Problem Summary:
Queries against Kerberos-protected Paimon storage can fail with `Failed
to find latest snapshot id` and `Client cannot authenticate via: [TOKEN,
KERBEROS]`. Resolving a Table under authentication does not cover
snapshot I/O performed after the resolver returns. In addition, storage
configured with `hdfs.authentication.*` can initialize without a
plugin-side Kerberos authenticator because the selection checks only the
raw legacy Hadoop key.
Authenticate actual snapshot-cache loads, partition-cache key loads, and
the complete time-travel resolution. Reuse the metadata-read wrapper and
select the storage authenticator from the effective Hadoop
configuration, including normalized storage properties. HMS and storage
identities remain separate; cache behavior and empty-table snapshot
semantics are preserved.
The CHAR-padding regression asserts SQL rejection of complex MAP keys,
matching the upstream analyzer guard, while retaining primitive-key
padding/collision and DML coverage. The Variant NULL fixture correction
is already supplied by master through #69261.
### Release note
Fix Kerberos-authenticated Paimon snapshot reads and recognize
normalized HDFS authentication settings when selecting the plugin
storage identity.
### Check List (For Author)
- [x] Unit Test
- [x] Regression test
- [x] Manual test
- Behavior changed: authenticated Paimon metadata reads retain the
storage identity through deferred file I/O.
- Documentation needed: No; no configuration key or public API added.
Validation:
- External CI follow-up: reproduced the full
`test_paimon_write_variant_errors` suite failure on the original
`c5d8569a085` DEBUG/ASAN artifact with isolated Spark 4/Paimon 1.3.1 and
object storage. Regenerated the output with the regression runner and
reran the full suite: one passed, zero failures/fatal errors/skips.
- Reproduced 11 snapshot authentication scenarios before the fix. Added
a negative authentication/cache recovery control; all 12 pass after the
fix.
- Reproduced the normalized-storage-authenticator assertion failure
before fixing the legacy-only gate.
- Revalidated after rebasing onto master `1eb427f19eb`: Paimon module
`install` with `Paimon*` tests: 823 tests, zero failures/errors, one
skipped live-connectivity test. Checkstyle and plugin packaging passed.
- Local KDC/HMS/HDFS using the original failing DEBUG/ASAN runtime: both
Paimon cases failed at the first snapshot read before the patch.
Replacing the Paimon plugin with the built artifact made both local
suites pass (two suites, zero failed/fatal/skipped), including all four
Kerberos HMS property variants, JNI/native reads, incremental reads,
time travel, system tables, and the added REFRESH assertion.
- Local adaptations replace endpoints/keytab paths and exclude unrelated
object-store variants. The migration fixture has matching columns and
four rows, created as local Paimon tables; it does not reproduce the
original Hive-migrated physical files.
- Ten concurrent FE plans across two Kerberos identities succeeded; a
mismatched keytab was rejected. A separate intermittent BE credential
error observed with a warm backend is not claimed fixed here; the final
SQL run used a fresh backend.
Rebase validation (master `41a58afba2e`):
- Resolved the conflict by retaining master's Variant fix from #69261
and removing this PR's duplicate Variant change. The output is
identical; only the explanatory comment uses the upstream wording.
- Original product fixes and the CHAR-padding regression fix are
preserved. Range-diff confirms the unchanged patches; the Iceberg
product patch is also unchanged where applicable.
- Paimon connector `install`: 832 tests, zero failures/errors, one
live-connectivity skip; Checkstyle and plugin packaging passed.
- The end-to-end results listed below were obtained before this rebase.
Full integration validation of the new head is pending CI.
CHAR-padding regression follow-up:
- The upstream MAP-key guard (#69101) rejects STRUCT keys before the
Paimon sink, while the CHAR-padding suite (#69083) still expected those
writes to succeed or reach writer collision checks.
- All four complex-key groups now assert the specific analysis error for
both former success/collision inputs, and verify their tables remain
empty. Primitive CHAR-key padding/collision checks and partition, ORC,
bucket-routing, rowkind, UPDATE/DELETE, and MERGE coverage are retained.
Direct writer normalization tests remain unchanged.
- Reproduced the original full-suite failure on the CI runtime built
from `86cd8732c93`. Regenerated expected output with the regression
runner, then independently ran the complete
`test_paimon_write_char_padding` and `test_paimon_write_variant_errors`
suites: two passed, zero failures/fatal errors/skips. Only local
endpoints were adapted; product binaries were unchanged.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../doris/connector/paimon/PaimonConnector.java | 8 +-
.../connector/paimon/PaimonConnectorMetadata.java | 29 +++--
.../PaimonConnectorPluginAuthenticatorTest.java | 15 +++
.../paimon/PaimonSnapshotAuthenticationTest.java | 127 +++++++++++++++++++++
.../paimon/RecordingPaimonCatalogOps.java | 13 +++
.../write/test_paimon_write_char_padding.out | 16 +--
.../write/test_paimon_write_char_padding.groovy | 85 ++++++--------
.../paimon/test_paimon_hms_catalog.groovy | 4 +
8 files changed, 228 insertions(+), 69 deletions(-)
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 094883afb03..a5154b81f86 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
@@ -216,9 +216,11 @@ public class PaimonConnector implements Connector {
*/
static HadoopAuthenticator buildPluginAuthenticator(Map<String, String>
properties,
Map<String, String> storageHadoopConfig) {
- if
("kerberos".equalsIgnoreCase(properties.get(HADOOP_SECURITY_AUTHENTICATION))) {
- return HadoopAuthenticator.getHadoopAuthenticator(
- PaimonCatalogFactory.buildHadoopConfiguration(properties,
storageHadoopConfig));
+ // Storage bindings normalize hdfs.authentication.* and XML settings
into Hadoop keys.
+ // Gating on the raw legacy key would leave FileIO using the plugin's
unauthenticated UGI.
+ Configuration conf =
PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig);
+ if
("kerberos".equalsIgnoreCase(conf.get(HADOOP_SECURITY_AUTHENTICATION))) {
+ return HadoopAuthenticator.getHadoopAuthenticator(conf);
}
return null;
}
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 b6dd5993eb2..245da688dd8 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
@@ -311,7 +311,7 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
// are not DataTable). Sharing buildTableSchema with the at-snapshot
path keeps the two from drifting.
if (!paimonHandle.isSystemTable()) {
Optional<PaimonCatalogOps.PaimonSchemaSnapshot> latest =
- readSchemaAuthenticated(() ->
catalogOps.latestSchema(table));
+ readMetadataAuthenticated(() ->
catalogOps.latestSchema(table));
if (latest.isPresent()) {
PaimonCatalogOps.PaimonSchemaSnapshot schema = latest.get();
return buildTableSchema(
@@ -634,7 +634,8 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
// 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));
+ () -> readMetadataAuthenticated(
+ () ->
catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L)));
StatementPin pin = statementPin(paimonHandle,
resolveTable(paimonHandle), id);
Map<String, String> coordinates = new HashMap<>(pin.coordinates);
if (!coordinates.isEmpty()) {
@@ -644,19 +645,19 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
.retainSchema(pin.schemaId() >=
0).properties(coordinates).build());
}
- private <T> T readSchemaAuthenticated(Supplier<T> read) {
- // Cached tables do not cache schema files: exact/latest schema reads
still need plugin UGI and TCCL.
+ private <T> T readMetadataAuthenticated(Supplier<T> read) {
+ // Table resolution can return before lazy schema/snapshot I/O;
authenticate the actual read as well.
try {
return context.executeAuthenticated(read::get);
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
- throw new RuntimeException("Failed to read Paimon schema", e);
+ throw new RuntimeException("Failed to read Paimon metadata", e);
}
}
private StatementPin statementPin(PaimonTableHandle handle, Table table,
long snapshotId) {
- return statementPins.computeIfAbsent(handle, ignored ->
readSchemaAuthenticated(() -> {
+ return statementPins.computeIfAbsent(handle, ignored ->
readMetadataAuthenticated(() -> {
Optional<PaimonCatalogOps.PaimonSchemaSnapshot> schema =
catalogOps.latestSchema(table);
long schemaId =
schema.map(PaimonCatalogOps.PaimonSchemaSnapshot::schemaId).orElse(-1L);
Map<String, String> coordinates = PaimonSchemaPin.capture(table,
schemaId, snapshotId);
@@ -669,9 +670,9 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
private PaimonCatalogOps.PaimonSchemaSnapshot
schemaForPin(PaimonTableHandle handle, Table table, long schemaId) {
if (!PaimonScanParams.preservesBoundSchema(handle.getScanOptions())) {
return schemaAtMemo.getOrLoad(handle, schemaId,
- () -> readSchemaAuthenticated(() ->
catalogOps.schemaAt(table, schemaId)));
+ () -> readMetadataAuthenticated(() ->
catalogOps.schemaAt(table, schemaId)));
}
- return readSchemaAuthenticated(() -> {
+ return readMetadataAuthenticated(() -> {
PaimonSchemaPin.validate(table, handle.getScanOptions());
// INSERT replanning can replace this scope while retaining the
source pin. Rehydrate
// the exact schema in the new scope without using the catalog's
historical memo.
@@ -752,6 +753,11 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
if (paimonHandle.isSystemTable()) {
return Optional.empty();
}
+ return readMetadataAuthenticated(() ->
resolveTimeTravelAuthenticated(session, paimonHandle, spec));
+ }
+
+ private Optional<ConnectorMvccSnapshot> resolveTimeTravelAuthenticated(
+ ConnectorSession session, PaimonTableHandle paimonHandle,
ConnectorTimeTravelSpec spec) {
Table table = resolveTable(paimonHandle);
switch (spec.getKind()) {
case SNAPSHOT_ID: {
@@ -1559,7 +1565,7 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
// rowType(). System tables keep their synthetic rowType() (no
schema-version history).
if (!paimonHandle.isSystemTable()) {
Optional<PaimonCatalogOps.PaimonSchemaSnapshot> latest =
- readSchemaAuthenticated(() ->
catalogOps.latestSchema(table));
+ readMetadataAuthenticated(() ->
catalogOps.latestSchema(table));
if (latest.isPresent()) {
return buildColumnHandles(latest.get().fields(), true);
}
@@ -1740,7 +1746,8 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
private ConnectorTableKey partitionViewCacheKey(PaimonTableHandle
paimonHandle) {
Identifier identifier =
Identifier.create(paimonHandle.getDatabaseName(), paimonHandle.getTableName());
long snapshotId = latestSnapshotCache.getOrLoad(identifier,
- () ->
catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L));
+ () -> readMetadataAuthenticated(
+ () ->
catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L)));
return new ConnectorTableKey(
paimonHandle.getDatabaseName(), paimonHandle.getTableName(),
snapshotId, -1L);
}
@@ -1991,7 +1998,7 @@ public class PaimonConnectorMetadata implements
ConnectorMetadata {
}
Table dataTable = PaimonTableResolver.resolveSystemSource(catalogOps,
handle, context);
if (PaimonScanParams.preservesBoundSchema(scanOptions)) {
- return readSchemaAuthenticated(() ->
PaimonReaderOptions.runtimeSafeSystemTable(
+ return readMetadataAuthenticated(() ->
PaimonReaderOptions.runtimeSafeSystemTable(
handle.getSysTableName(), systemTable, dataTable,
scanOptions));
}
return PaimonReaderOptions.runtimeSafeSystemTable(
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
index d965ef84453..759b7e30297 100644
---
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
@@ -18,6 +18,7 @@
package org.apache.doris.connector.paimon;
import org.apache.doris.kerberos.HadoopAuthenticator;
+import org.apache.doris.kerberos.HadoopKerberosAuthenticator;
import org.apache.hadoop.conf.Configuration;
import org.apache.paimon.hive.pool.CachedClientPool;
@@ -63,6 +64,20 @@ public class PaimonConnectorPluginAuthenticatorTest {
Assertions.assertNotNull(auth, "storage kerberos must yield a plugin
authenticator");
}
+ @Test
+ public void canonicalStorageKerberosBuildsPluginAuthenticator() {
+ HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator(
+ props("paimon.catalog.type", "hms", "warehouse",
"hdfs://ns/warehouse",
+ "hdfs.authentication.type", "kerberos",
+ "hdfs.authentication.kerberos.principal",
"[email protected]",
+ "hdfs.authentication.kerberos.keytab",
"/etc/security/storage.keytab"),
+ props("hadoop.security.authentication", "kerberos",
+ "hadoop.kerberos.principal", "[email protected]",
+ "hadoop.kerberos.keytab",
"/etc/security/storage.keytab"));
+ Assertions.assertTrue(auth instanceof HadoopKerberosAuthenticator,
+ "Canonical storage configuration must select the plugin-side
Kerberos UGI");
+ }
+
/**
* 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
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSnapshotAuthenticationTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSnapshotAuthenticationTest.java
new file mode 100644
index 00000000000..38f566cbc8e
--- /dev/null
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonSnapshotAuthenticationTest.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.doris.connector.cache.ConnectorMetadataCache;
+import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
+
+import org.apache.paimon.catalog.Identifier;
+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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.OptionalLong;
+import java.util.stream.Stream;
+
+public class PaimonSnapshotAuthenticationTest {
+ private final RecordingConnectorContext context = new
RecordingConnectorContext();
+ private final RecordingPaimonCatalogOps ops = new
RecordingPaimonCatalogOps();
+
+ private PaimonTableHandle handle() {
+ FakePaimonTable table = new FakePaimonTable("t",
+ RowType.builder().field("region", DataTypes.STRING()).build(),
+ Collections.singletonList("region"), Collections.emptyList());
+ ops.table = table;
+ ops.latestSnapshotId = OptionalLong.of(7L);
+ ops.snapshotReadAuthentication = () -> context.authDepth > 0;
+ PaimonTableHandle handle = new PaimonTableHandle("db", "t",
+ Collections.singletonList("region"), Collections.emptyList());
+ handle.setPaimonTable(table);
+ return handle;
+ }
+
+ private PaimonConnectorMetadata metadata(PaimonLatestSnapshotCache cache) {
+ return new PaimonConnectorMetadata(ops,
PaimonCatalogProperties.of(Collections.emptyMap()), context,
+ new PaimonSchemaAtMemo(10), cache,
+ new ConnectorMetadataCache<>("paimon", "partition_view",
Collections.emptyMap()));
+ }
+
+ @ParameterizedTest
+ @ValueSource(longs = {0L, 60L})
+ public void snapshotCacheLoadsInsideAuthentication(long ttl) {
+ PaimonTableHandle handle = handle();
+ PaimonLatestSnapshotCache cache = new PaimonLatestSnapshotCache(ttl,
10);
+ Assertions.assertEquals(7L, metadata(cache).beginQuerySnapshot(null,
handle).get().getSnapshotId());
+ ops.latestSnapshotId = OptionalLong.of(8L);
+ Assertions.assertEquals(ttl == 0 ? 8L : 7L,
+ metadata(cache).beginQuerySnapshot(null,
handle).get().getSnapshotId());
+ cache.invalidate(Identifier.create("db", "t"));
+ Assertions.assertEquals(8L, metadata(cache).beginQuerySnapshot(null,
handle).get().getSnapshotId());
+ Assertions.assertEquals(0, context.authDepth);
+ }
+
+ @Test
+ public void partitionCacheKeyLoadsSnapshotInsideAuthentication() {
+ PaimonTableHandle handle = handle();
+ Assertions.assertTrue(metadata(new PaimonLatestSnapshotCache(0, 10))
+ .listPartitionNames(null, handle).isEmpty());
+ Assertions.assertTrue(ops.log.contains("latestSnapshotId"));
+ Assertions.assertEquals(0, context.authDepth);
+ }
+
+ @Test
+ public void emptySnapshotRemainsAnEmptyPin() {
+ PaimonTableHandle handle = handle();
+ ops.latestSnapshotId = OptionalLong.empty();
+ Assertions.assertEquals(-1L, metadata(new PaimonLatestSnapshotCache(0,
10))
+ .beginQuerySnapshot(null, handle).get().getSnapshotId());
+ }
+
+ @Test
+ public void authenticationFailureDoesNotCacheAnEmptySnapshot() {
+ PaimonTableHandle handle = handle();
+ PaimonLatestSnapshotCache cache = new PaimonLatestSnapshotCache(60,
10);
+ context.failAuth = true;
+ Assertions.assertThrows(RuntimeException.class, () ->
metadata(cache).beginQuerySnapshot(null, handle));
+ Assertions.assertFalse(ops.log.contains("latestSnapshotId"));
+ context.failAuth = false;
+ Assertions.assertEquals(7L, metadata(cache).beginQuerySnapshot(null,
handle).get().getSnapshotId());
+ Assertions.assertEquals(0, context.authDepth);
+ }
+
+ static Stream<ConnectorTimeTravelSpec> timeTravelSpecs() {
+ return Stream.of(ConnectorTimeTravelSpec.snapshotId("7"),
+ ConnectorTimeTravelSpec.timestamp("1700000000000", true),
+ ConnectorTimeTravelSpec.tag("release"),
+ ConnectorTimeTravelSpec.versionRef("release"),
+ ConnectorTimeTravelSpec.branch("branch"),
+ ConnectorTimeTravelSpec.incremental(Map.of("startSnapshotId",
"1", "endSnapshotId", "7")),
+ ConnectorTimeTravelSpec.options(Collections.emptyMap()));
+ }
+
+ @ParameterizedTest
+ @MethodSource("timeTravelSpecs")
+ public void timeTravelReadsInsideAuthentication(ConnectorTimeTravelSpec
spec) {
+ PaimonTableHandle handle = handle();
+ ops.snapshotExists = true;
+ ops.branchExists = true;
+ ops.branchTable = ops.table;
+ ops.tagSnapshot = new PaimonCatalogOps.TagSnapshot(7L, 1L);
+ ops.snapshotIdAtOrBefore = OptionalLong.of(7L);
+ ops.snapshotSchemaId = OptionalLong.of(1L);
+ Assertions.assertEquals(7L, metadata(new PaimonLatestSnapshotCache(0,
10))
+ .resolveTimeTravel(null, handle, spec).get().getSnapshotId());
+ Assertions.assertEquals(0, context.authDepth);
+ }
+}
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
index d5bf7252fc5..73147f40620 100644
---
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
@@ -95,6 +95,7 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
// ---- T20 E5 MVCC seam: configurable lookup results (no real
Snapshot/SnapshotManager) ----
OptionalLong latestSnapshotId = OptionalLong.empty();
BooleanSupplier latestSnapshotAuthentication;
+ BooleanSupplier snapshotReadAuthentication;
OptionalLong snapshotIdAtOrBefore = OptionalLong.empty();
boolean snapshotExists;
/** The table the metadata layer passed to the most recent MVCC seam call.
*/
@@ -252,8 +253,15 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
lastSchemaChanges = changes;
}
+ private void checkSnapshotAuthentication() {
+ if (snapshotReadAuthentication != null &&
!snapshotReadAuthentication.getAsBoolean()) {
+ throw new IllegalStateException("Snapshot I/O ran outside
authentication");
+ }
+ }
+
@Override
public OptionalLong latestSnapshotId(Table table) {
+ checkSnapshotAuthentication();
if (latestSnapshotAuthentication != null &&
!latestSnapshotAuthentication.getAsBoolean()) {
throw new IllegalStateException("latestSnapshotId called outside
authentication");
}
@@ -264,6 +272,7 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
@Override
public OptionalLong snapshotIdAtOrBefore(Table table, long
timestampMillis) {
+ checkSnapshotAuthentication();
log.add("snapshotIdAtOrBefore:" + timestampMillis);
lastMvccTable = table;
snapshotIdAtOrBeforeArg = timestampMillis;
@@ -272,6 +281,7 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
@Override
public boolean snapshotExists(Table table, long snapshotId) {
+ checkSnapshotAuthentication();
log.add("snapshotExists:" + snapshotId);
lastMvccTable = table;
return snapshotExists;
@@ -279,6 +289,7 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
@Override
public OptionalLong snapshotSchemaId(Table table, long snapshotId) {
+ checkSnapshotAuthentication();
log.add("snapshotSchemaId:" + snapshotId);
lastMvccTable = table;
lastSnapshotSchemaIdArg = snapshotId;
@@ -287,6 +298,7 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
@Override
public java.util.Optional<PaimonCatalogOps.TagSnapshot>
getSnapshotByTag(Table table, String tagName) {
+ checkSnapshotAuthentication();
log.add("getSnapshotByTag:" + tagName);
lastMvccTable = table;
lastTagNameArg = tagName;
@@ -310,6 +322,7 @@ final class RecordingPaimonCatalogOps implements
PaimonCatalogOps {
@Override
public boolean branchExists(Table table, String branchName) {
+ checkSnapshotAuthentication();
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).
diff --git
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_char_padding.out
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_char_padding.out
index dba5910049d..e1a699d7a60 100644
---
a/regression-test/data/external_table_p0/paimon/write/test_paimon_write_char_padding.out
+++
b/regression-test/data/external_table_p0/paimon/write/test_paimon_write_char_padding.out
@@ -12,17 +12,17 @@
2 2 [k |kk] 1|2
3 2 [k|k ] 1|2
--- !map_row_key_padded --
-1 1 [k ] 7
+-- !map_row_key_rejected --
+0
--- !map_nested_map_key_padded --
-2 1 [k ] 10
+-- !map_nested_map_key_rejected --
+0
--- !map_nan_key_padded --
-2 1 [k ] NaN 10
+-- !map_nan_key_rejected --
+0
--- !map_nested_nan_key_padded --
-2 2 [k ]|[k ] 10|20
+-- !map_nested_nan_key_rejected --
+0
-- !partition_padded --
1 [x ]
diff --git
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_char_padding.groovy
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_char_padding.groovy
index f0631de6f2b..cdb8602d454 100644
---
a/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_char_padding.groovy
+++
b/regression-test/suites/external_table_p0/paimon/write/test_paimon_write_char_padding.groovy
@@ -265,41 +265,37 @@ suite("test_paimon_write_char_padding",
"p0,external,paimon") {
FROM t_char_map_avro
"""
- // The writer takes a map key from the Arrow batch BE sends, where a
ROW key holding a ROW is a columnar row
- // that cannot be hashed, so keys are compared by their bytes: such a
key is padded, and two keys that
- // padding makes equal fail the row as above.
- sql """INSERT INTO t_char_map_row_key VALUES (1, map(named_struct('c',
'k', 'r', named_struct('i', 1)), 7))"""
+ // SQL rejects complex MAP keys before the Paimon writer runs, even
when padding would keep them distinct.
+ // PaimonWriteSchemaTest covers the writer's complex-key normalization
directly; these statements check
+ // the SQL boundary and that rejected writes leave each table empty.
+ test {
+ sql """INSERT INTO t_char_map_row_key VALUES (1,
map(named_struct('c', 'k', 'r', named_struct('i', 1)), 7))"""
+ exception "MAP key type must be a primitive type"
+ }
test {
sql """
INSERT INTO t_char_map_row_key VALUES (2,
map(named_struct('c', 'k', 'r', named_struct('i', 1)), 1,
named_struct('c', 'k ', 'r', named_struct('i', 1)), 2))
"""
- exception "into the same key"
+ exception "MAP key type must be a primitive type"
}
- order_qt_map_row_key_padded """
- SELECT id, map_size(m), concat('[', struct_element(map_keys(m)[1],
'c'), ']'), map_values(m)[1]
- FROM t_char_map_row_key
- """
+ order_qt_map_row_key_rejected """SELECT count(*) FROM
t_char_map_row_key"""
- // Paimon compares maps regardless of the order of their entries, and
its Avro reader keeps one of two keys
- // whose nested maps differ only in that order, so such keys that
padding makes equal fail the row too. A
- // key holding a map is padded.
+ // A STRUCT containing a MAP is still a complex key, regardless of the
nested entry order.
test {
sql """
INSERT INTO t_char_map_nested_map_key VALUES (1,
map(named_struct('c', 'k', 'm', map('a', 1, 'b', 2)),
10, named_struct('c', 'k ', 'm', map('b', 2, 'a', 1)), 20))
"""
- exception "into the same key"
+ exception "MAP key type must be a primitive type"
}
- sql """INSERT INTO t_char_map_nested_map_key VALUES (2,
map(named_struct('c', 'k', 'm', map('a', 1)), 10))"""
- order_qt_map_nested_map_key_padded """
- SELECT id, map_size(m), concat('[', struct_element(map_keys(m)[1],
'c'), ']'), map_values(m)[1]
- FROM t_char_map_nested_map_key
- """
+ test {
+ sql """INSERT INTO t_char_map_nested_map_key VALUES (2,
map(named_struct('c', 'k', 'm', map('a', 1)), 10))"""
+ exception "MAP key type must be a primitive type"
+ }
+ order_qt_map_nested_map_key_rejected """SELECT count(*) FROM
t_char_map_nested_map_key"""
- // Paimon compares a DOUBLE regardless of the bits of a NaN, and its
Avro reader keeps one of two keys that
- // differ only so, while a NaN cast at run time keeps its sign. So the
keys (k, NaN) and (k , -NaN), which
- // padding makes equal, fail the row too. A key holding a NaN is
padded.
+ // Runtime NaN values do not bypass the key-type check in INSERT
SELECT.
sql """INSERT INTO internal.${dbName}.char_nan_source VALUES ('NaN',
'-NaN')"""
test {
sql """
@@ -308,21 +304,18 @@ suite("test_paimon_write_char_padding",
"p0,external,paimon") {
named_struct('c', 'k ', 'd', cast(neg AS DOUBLE)), 20)
FROM internal.${dbName}.char_nan_source
"""
- exception "into the same key"
+ exception "MAP key type must be a primitive type"
}
- sql """
- INSERT INTO t_char_map_nan_key
- SELECT 2, map(named_struct('c', 'k', 'd', cast(neg AS DOUBLE)),
10) FROM internal.${dbName}.char_nan_source
- """
- order_qt_map_nan_key_padded """
- SELECT id, map_size(m), concat('[', struct_element(map_keys(m)[1],
'c'), ']'),
- struct_element(map_keys(m)[1], 'd'), map_values(m)[1]
- FROM t_char_map_nan_key
- """
+ test {
+ sql """
+ INSERT INTO t_char_map_nan_key
+ SELECT 2, map(named_struct('c', 'k', 'd', cast(neg AS
DOUBLE)), 10) FROM internal.${dbName}.char_nan_source
+ """
+ exception "MAP key type must be a primitive type"
+ }
+ order_qt_map_nan_key_rejected """SELECT count(*) FROM
t_char_map_nan_key"""
- // Paimon's Avro reader keeps the last of the entries of a map whose
keys are equal, so a nested map holding
- // NaN -> 1 and -NaN -> 2 reads back as {NaN: 2}, and the keys (k,
{NaN: 1, -NaN: 2}) and (k , {NaN: 2}),
- // which padding then makes equal, fail the row too. Keys whose nested
maps keep other last values are padded.
+ // Nesting those NaNs inside another MAP also fails at the outer
STRUCT key.
test {
sql """
INSERT INTO t_char_map_nested_nan_key
@@ -330,20 +323,18 @@ suite("test_paimon_write_char_padding",
"p0,external,paimon") {
named_struct('c', 'k ', 'm', map(cast(pos AS DOUBLE), 2)),
20)
FROM internal.${dbName}.char_nan_source
"""
- exception "into the same key"
+ exception "MAP key type must be a primitive type"
}
- sql """
- INSERT INTO t_char_map_nested_nan_key
- SELECT 2, map(named_struct('c', 'k', 'm', map(cast(pos AS DOUBLE),
1, cast(neg AS DOUBLE), 2)), 10,
- named_struct('c', 'k ', 'm', map(cast(pos AS DOUBLE), 1)), 20)
- FROM internal.${dbName}.char_nan_source
- """
- order_qt_map_nested_nan_key_padded """
- SELECT id, map_size(m),
- array_join(array_sort(array_map(k -> concat('[',
struct_element(k, 'c'), ']'), map_keys(m))), '|'),
- array_join(array_sort(map_values(m)), '|')
- FROM t_char_map_nested_nan_key
- """
+ test {
+ sql """
+ INSERT INTO t_char_map_nested_nan_key
+ SELECT 2, map(named_struct('c', 'k', 'm', map(cast(pos AS
DOUBLE), 1, cast(neg AS DOUBLE), 2)), 10,
+ named_struct('c', 'k ', 'm', map(cast(pos AS DOUBLE), 1)),
20)
+ FROM internal.${dbName}.char_nan_source
+ """
+ exception "MAP key type must be a primitive type"
+ }
+ order_qt_map_nested_nan_key_rejected """SELECT count(*) FROM
t_char_map_nested_nan_key"""
// A partition value is padded whether it comes from the rows or from
a static PARTITION clause, so a
// second static overwrite of 'x' replaces the partition the first one
wrote.
diff --git
a/regression-test/suites/external_table_p2/paimon/test_paimon_hms_catalog.groovy
b/regression-test/suites/external_table_p2/paimon/test_paimon_hms_catalog.groovy
index 722b50531f5..1ef30d8a94e 100644
---
a/regression-test/suites/external_table_p2/paimon/test_paimon_hms_catalog.groovy
+++
b/regression-test/suites/external_table_p2/paimon/test_paimon_hms_catalog.groovy
@@ -54,6 +54,10 @@ suite("test_paimon_hms_catalog", "p2,external") {
sql """SELECT * FROM external_test_table FOR VERSION AS OF 1;"""
// branch/tag
// TODO(zgx): add branch/tag
+ // REFRESH must reload snapshots with the catalog identity, even after
a warm query.
+ def beforeRefresh = sql """SELECT * FROM external_test_table ORDER BY
1, 2"""
+ sql """REFRESH TABLE external_test_table"""
+ assertEquals(beforeRefresh, sql("""SELECT * FROM external_test_table
ORDER BY 1, 2"""))
// system table
sql """SELECT * FROM external_test_table\$snapshots;"""
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]