Skip to content

Commit c5d8569

Browse files
committed
[fix](paimon) Authenticate deferred metadata reads and normalized HDFS storage
1 parent 1eb427f commit c5d8569

6 files changed

Lines changed: 182 additions & 14 deletions

File tree

‎fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,11 @@ private HadoopAuthenticator pluginAuthenticator() {
216216
*/
217217
static HadoopAuthenticator buildPluginAuthenticator(Map<String, String> properties,
218218
Map<String, String> storageHadoopConfig) {
219-
if ("kerberos".equalsIgnoreCase(properties.get(HADOOP_SECURITY_AUTHENTICATION))) {
220-
return HadoopAuthenticator.getHadoopAuthenticator(
221-
PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig));
219+
// Storage bindings normalize hdfs.authentication.* and XML settings into Hadoop keys.
220+
// Gating on the raw legacy key would leave FileIO using the plugin's unauthenticated UGI.
221+
Configuration conf = PaimonCatalogFactory.buildHadoopConfiguration(properties, storageHadoopConfig);
222+
if ("kerberos".equalsIgnoreCase(conf.get(HADOOP_SECURITY_AUTHENTICATION))) {
223+
return HadoopAuthenticator.getHadoopAuthenticator(conf);
222224
}
223225
return null;
224226
}

‎fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnectorMetadata.java‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ public ConnectorTableSchema getTableSchema(
310310
// are not DataTable). Sharing buildTableSchema with the at-snapshot path keeps the two from drifting.
311311
if (!paimonHandle.isSystemTable()) {
312312
Optional<PaimonCatalogOps.PaimonSchemaSnapshot> latest =
313-
readSchemaAuthenticated(() -> catalogOps.latestSchema(table));
313+
readMetadataAuthenticated(() -> catalogOps.latestSchema(table));
314314
if (latest.isPresent()) {
315315
PaimonCatalogOps.PaimonSchemaSnapshot schema = latest.get();
316316
return buildTableSchema(
@@ -633,7 +633,8 @@ public Optional<ConnectorMvccSnapshot> beginQuerySnapshot(
633633
// on a miss; when caching is disabled (ttl-second<=0, the no-cache catalog) it runs every call.
634634
Identifier identifier = Identifier.create(paimonHandle.getDatabaseName(), paimonHandle.getTableName());
635635
long id = latestSnapshotCache.getOrLoad(identifier,
636-
() -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L));
636+
() -> readMetadataAuthenticated(
637+
() -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L)));
637638
StatementPin pin = statementPin(paimonHandle, resolveTable(paimonHandle), id);
638639
Map<String, String> coordinates = new HashMap<>(pin.coordinates);
639640
if (!coordinates.isEmpty()) {
@@ -643,19 +644,19 @@ public Optional<ConnectorMvccSnapshot> beginQuerySnapshot(
643644
.retainSchema(pin.schemaId() >= 0).properties(coordinates).build());
644645
}
645646

646-
private <T> T readSchemaAuthenticated(Supplier<T> read) {
647-
// Cached tables do not cache schema files: exact/latest schema reads still need plugin UGI and TCCL.
647+
private <T> T readMetadataAuthenticated(Supplier<T> read) {
648+
// Table resolution can return before lazy schema/snapshot I/O; authenticate the actual read as well.
648649
try {
649650
return context.executeAuthenticated(read::get);
650651
} catch (RuntimeException e) {
651652
throw e;
652653
} catch (Exception e) {
653-
throw new RuntimeException("Failed to read Paimon schema", e);
654+
throw new RuntimeException("Failed to read Paimon metadata", e);
654655
}
655656
}
656657

657658
private StatementPin statementPin(PaimonTableHandle handle, Table table, long snapshotId) {
658-
return statementPins.computeIfAbsent(handle, ignored -> readSchemaAuthenticated(() -> {
659+
return statementPins.computeIfAbsent(handle, ignored -> readMetadataAuthenticated(() -> {
659660
Optional<PaimonCatalogOps.PaimonSchemaSnapshot> schema = catalogOps.latestSchema(table);
660661
long schemaId = schema.map(PaimonCatalogOps.PaimonSchemaSnapshot::schemaId).orElse(-1L);
661662
Map<String, String> coordinates = PaimonSchemaPin.capture(table, schemaId, snapshotId);
@@ -668,9 +669,9 @@ private StatementPin statementPin(PaimonTableHandle handle, Table table, long sn
668669
private PaimonCatalogOps.PaimonSchemaSnapshot schemaForPin(PaimonTableHandle handle, Table table, long schemaId) {
669670
if (!PaimonScanParams.preservesBoundSchema(handle.getScanOptions())) {
670671
return schemaAtMemo.getOrLoad(handle, schemaId,
671-
() -> readSchemaAuthenticated(() -> catalogOps.schemaAt(table, schemaId)));
672+
() -> readMetadataAuthenticated(() -> catalogOps.schemaAt(table, schemaId)));
672673
}
673-
return readSchemaAuthenticated(() -> {
674+
return readMetadataAuthenticated(() -> {
674675
PaimonSchemaPin.validate(table, handle.getScanOptions());
675676
// INSERT replanning can replace this scope while retaining the source pin. Rehydrate
676677
// the exact schema in the new scope without using the catalog's historical memo.
@@ -751,6 +752,11 @@ public Optional<ConnectorMvccSnapshot> resolveTimeTravel(
751752
if (paimonHandle.isSystemTable()) {
752753
return Optional.empty();
753754
}
755+
return readMetadataAuthenticated(() -> resolveTimeTravelAuthenticated(session, paimonHandle, spec));
756+
}
757+
758+
private Optional<ConnectorMvccSnapshot> resolveTimeTravelAuthenticated(
759+
ConnectorSession session, PaimonTableHandle paimonHandle, ConnectorTimeTravelSpec spec) {
754760
Table table = resolveTable(paimonHandle);
755761
switch (spec.getKind()) {
756762
case SNAPSHOT_ID: {
@@ -1546,7 +1552,7 @@ public Map<String, ConnectorColumnHandle> getColumnHandles(
15461552
// rowType(). System tables keep their synthetic rowType() (no schema-version history).
15471553
if (!paimonHandle.isSystemTable()) {
15481554
Optional<PaimonCatalogOps.PaimonSchemaSnapshot> latest =
1549-
readSchemaAuthenticated(() -> catalogOps.latestSchema(table));
1555+
readMetadataAuthenticated(() -> catalogOps.latestSchema(table));
15501556
if (latest.isPresent()) {
15511557
return buildColumnHandles(latest.get().fields(), true);
15521558
}
@@ -1727,7 +1733,8 @@ private List<ConnectorPartitionInfo> cachedPartitions(PaimonTableHandle paimonHa
17271733
private ConnectorTableKey partitionViewCacheKey(PaimonTableHandle paimonHandle) {
17281734
Identifier identifier = Identifier.create(paimonHandle.getDatabaseName(), paimonHandle.getTableName());
17291735
long snapshotId = latestSnapshotCache.getOrLoad(identifier,
1730-
() -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L));
1736+
() -> readMetadataAuthenticated(
1737+
() -> catalogOps.latestSnapshotId(resolveTable(paimonHandle)).orElse(-1L)));
17311738
return new ConnectorTableKey(
17321739
paimonHandle.getDatabaseName(), paimonHandle.getTableName(), snapshotId, -1L);
17331740
}
@@ -1978,7 +1985,7 @@ private Table runtimeSafeSystemTable(
19781985
}
19791986
Table dataTable = PaimonTableResolver.resolveSystemSource(catalogOps, handle, context);
19801987
if (PaimonScanParams.preservesBoundSchema(scanOptions)) {
1981-
return readSchemaAuthenticated(() -> PaimonReaderOptions.runtimeSafeSystemTable(
1988+
return readMetadataAuthenticated(() -> PaimonReaderOptions.runtimeSafeSystemTable(
19821989
handle.getSysTableName(), systemTable, dataTable, scanOptions));
19831990
}
19841991
return PaimonReaderOptions.runtimeSafeSystemTable(

‎fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorPluginAuthenticatorTest.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
package org.apache.doris.connector.paimon;
1919

2020
import org.apache.doris.kerberos.HadoopAuthenticator;
21+
import org.apache.doris.kerberos.HadoopKerberosAuthenticator;
2122

2223
import org.apache.hadoop.conf.Configuration;
2324
import org.apache.paimon.hive.pool.CachedClientPool;
@@ -63,6 +64,20 @@ public void storageKerberosBuildsAuthenticator() {
6364
Assertions.assertNotNull(auth, "storage kerberos must yield a plugin authenticator");
6465
}
6566

67+
@Test
68+
public void canonicalStorageKerberosBuildsPluginAuthenticator() {
69+
HadoopAuthenticator auth = PaimonConnector.buildPluginAuthenticator(
70+
props("paimon.catalog.type", "hms", "warehouse", "hdfs://ns/warehouse",
71+
"hdfs.authentication.type", "kerberos",
72+
"hdfs.authentication.kerberos.principal", "[email protected]",
73+
"hdfs.authentication.kerberos.keytab", "/etc/security/storage.keytab"),
74+
props("hadoop.security.authentication", "kerberos",
75+
"hadoop.kerberos.principal", "[email protected]",
76+
"hadoop.kerberos.keytab", "/etc/security/storage.keytab"));
77+
Assertions.assertTrue(auth instanceof HadoopKerberosAuthenticator,
78+
"Canonical storage configuration must select the plugin-side Kerberos UGI");
79+
}
80+
6681
/**
6782
* THE S6 GAP: a Kerberized HMS whose data storage is simple. Storage auth is unset, so the storage gate is
6883
* off; the connector must fall back to the HMS client-principal/keytab facts and still build a plugin
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.doris.connector.paimon;
19+
20+
import org.apache.doris.connector.cache.ConnectorMetadataCache;
21+
import org.apache.doris.connector.spi.mvcc.ConnectorTimeTravelSpec;
22+
23+
import org.apache.paimon.catalog.Identifier;
24+
import org.apache.paimon.types.DataTypes;
25+
import org.apache.paimon.types.RowType;
26+
import org.junit.jupiter.api.Assertions;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.params.ParameterizedTest;
29+
import org.junit.jupiter.params.provider.MethodSource;
30+
import org.junit.jupiter.params.provider.ValueSource;
31+
32+
import java.util.Collections;
33+
import java.util.Map;
34+
import java.util.OptionalLong;
35+
import java.util.stream.Stream;
36+
37+
public class PaimonSnapshotAuthenticationTest {
38+
private final RecordingConnectorContext context = new RecordingConnectorContext();
39+
private final RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
40+
41+
private PaimonTableHandle handle() {
42+
FakePaimonTable table = new FakePaimonTable("t",
43+
RowType.builder().field("region", DataTypes.STRING()).build(),
44+
Collections.singletonList("region"), Collections.emptyList());
45+
ops.table = table;
46+
ops.latestSnapshotId = OptionalLong.of(7L);
47+
ops.snapshotReadAuthentication = () -> context.authDepth > 0;
48+
PaimonTableHandle handle = new PaimonTableHandle("db", "t",
49+
Collections.singletonList("region"), Collections.emptyList());
50+
handle.setPaimonTable(table);
51+
return handle;
52+
}
53+
54+
private PaimonConnectorMetadata metadata(PaimonLatestSnapshotCache cache) {
55+
return new PaimonConnectorMetadata(ops, PaimonCatalogProperties.of(Collections.emptyMap()), context,
56+
new PaimonSchemaAtMemo(10), cache,
57+
new ConnectorMetadataCache<>("paimon", "partition_view", Collections.emptyMap()));
58+
}
59+
60+
@ParameterizedTest
61+
@ValueSource(longs = {0L, 60L})
62+
public void snapshotCacheLoadsInsideAuthentication(long ttl) {
63+
PaimonTableHandle handle = handle();
64+
PaimonLatestSnapshotCache cache = new PaimonLatestSnapshotCache(ttl, 10);
65+
Assertions.assertEquals(7L, metadata(cache).beginQuerySnapshot(null, handle).get().getSnapshotId());
66+
ops.latestSnapshotId = OptionalLong.of(8L);
67+
Assertions.assertEquals(ttl == 0 ? 8L : 7L,
68+
metadata(cache).beginQuerySnapshot(null, handle).get().getSnapshotId());
69+
cache.invalidate(Identifier.create("db", "t"));
70+
Assertions.assertEquals(8L, metadata(cache).beginQuerySnapshot(null, handle).get().getSnapshotId());
71+
Assertions.assertEquals(0, context.authDepth);
72+
}
73+
74+
@Test
75+
public void partitionCacheKeyLoadsSnapshotInsideAuthentication() {
76+
PaimonTableHandle handle = handle();
77+
Assertions.assertTrue(metadata(new PaimonLatestSnapshotCache(0, 10))
78+
.listPartitionNames(null, handle).isEmpty());
79+
Assertions.assertTrue(ops.log.contains("latestSnapshotId"));
80+
Assertions.assertEquals(0, context.authDepth);
81+
}
82+
83+
@Test
84+
public void emptySnapshotRemainsAnEmptyPin() {
85+
PaimonTableHandle handle = handle();
86+
ops.latestSnapshotId = OptionalLong.empty();
87+
Assertions.assertEquals(-1L, metadata(new PaimonLatestSnapshotCache(0, 10))
88+
.beginQuerySnapshot(null, handle).get().getSnapshotId());
89+
}
90+
91+
@Test
92+
public void authenticationFailureDoesNotCacheAnEmptySnapshot() {
93+
PaimonTableHandle handle = handle();
94+
PaimonLatestSnapshotCache cache = new PaimonLatestSnapshotCache(60, 10);
95+
context.failAuth = true;
96+
Assertions.assertThrows(RuntimeException.class, () -> metadata(cache).beginQuerySnapshot(null, handle));
97+
Assertions.assertFalse(ops.log.contains("latestSnapshotId"));
98+
context.failAuth = false;
99+
Assertions.assertEquals(7L, metadata(cache).beginQuerySnapshot(null, handle).get().getSnapshotId());
100+
Assertions.assertEquals(0, context.authDepth);
101+
}
102+
103+
static Stream<ConnectorTimeTravelSpec> timeTravelSpecs() {
104+
return Stream.of(ConnectorTimeTravelSpec.snapshotId("7"),
105+
ConnectorTimeTravelSpec.timestamp("1700000000000", true),
106+
ConnectorTimeTravelSpec.tag("release"),
107+
ConnectorTimeTravelSpec.versionRef("release"),
108+
ConnectorTimeTravelSpec.branch("branch"),
109+
ConnectorTimeTravelSpec.incremental(Map.of("startSnapshotId", "1", "endSnapshotId", "7")),
110+
ConnectorTimeTravelSpec.options(Collections.emptyMap()));
111+
}
112+
113+
@ParameterizedTest
114+
@MethodSource("timeTravelSpecs")
115+
public void timeTravelReadsInsideAuthentication(ConnectorTimeTravelSpec spec) {
116+
PaimonTableHandle handle = handle();
117+
ops.snapshotExists = true;
118+
ops.branchExists = true;
119+
ops.branchTable = ops.table;
120+
ops.tagSnapshot = new PaimonCatalogOps.TagSnapshot(7L, 1L);
121+
ops.snapshotIdAtOrBefore = OptionalLong.of(7L);
122+
ops.snapshotSchemaId = OptionalLong.of(1L);
123+
Assertions.assertEquals(7L, metadata(new PaimonLatestSnapshotCache(0, 10))
124+
.resolveTimeTravel(null, handle, spec).get().getSnapshotId());
125+
Assertions.assertEquals(0, context.authDepth);
126+
}
127+
}

‎fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/RecordingPaimonCatalogOps.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ final class RecordingPaimonCatalogOps implements PaimonCatalogOps {
9595
// ---- T20 E5 MVCC seam: configurable lookup results (no real Snapshot/SnapshotManager) ----
9696
OptionalLong latestSnapshotId = OptionalLong.empty();
9797
BooleanSupplier latestSnapshotAuthentication;
98+
BooleanSupplier snapshotReadAuthentication;
9899
OptionalLong snapshotIdAtOrBefore = OptionalLong.empty();
99100
boolean snapshotExists;
100101
/** The table the metadata layer passed to the most recent MVCC seam call. */
@@ -252,8 +253,15 @@ public void alterTable(Identifier identifier, List<SchemaChange> changes) {
252253
lastSchemaChanges = changes;
253254
}
254255

256+
private void checkSnapshotAuthentication() {
257+
if (snapshotReadAuthentication != null && !snapshotReadAuthentication.getAsBoolean()) {
258+
throw new IllegalStateException("Snapshot I/O ran outside authentication");
259+
}
260+
}
261+
255262
@Override
256263
public OptionalLong latestSnapshotId(Table table) {
264+
checkSnapshotAuthentication();
257265
if (latestSnapshotAuthentication != null && !latestSnapshotAuthentication.getAsBoolean()) {
258266
throw new IllegalStateException("latestSnapshotId called outside authentication");
259267
}
@@ -264,6 +272,7 @@ public OptionalLong latestSnapshotId(Table table) {
264272

265273
@Override
266274
public OptionalLong snapshotIdAtOrBefore(Table table, long timestampMillis) {
275+
checkSnapshotAuthentication();
267276
log.add("snapshotIdAtOrBefore:" + timestampMillis);
268277
lastMvccTable = table;
269278
snapshotIdAtOrBeforeArg = timestampMillis;
@@ -272,13 +281,15 @@ public OptionalLong snapshotIdAtOrBefore(Table table, long timestampMillis) {
272281

273282
@Override
274283
public boolean snapshotExists(Table table, long snapshotId) {
284+
checkSnapshotAuthentication();
275285
log.add("snapshotExists:" + snapshotId);
276286
lastMvccTable = table;
277287
return snapshotExists;
278288
}
279289

280290
@Override
281291
public OptionalLong snapshotSchemaId(Table table, long snapshotId) {
292+
checkSnapshotAuthentication();
282293
log.add("snapshotSchemaId:" + snapshotId);
283294
lastMvccTable = table;
284295
lastSnapshotSchemaIdArg = snapshotId;
@@ -287,6 +298,7 @@ public OptionalLong snapshotSchemaId(Table table, long snapshotId) {
287298

288299
@Override
289300
public java.util.Optional<PaimonCatalogOps.TagSnapshot> getSnapshotByTag(Table table, String tagName) {
301+
checkSnapshotAuthentication();
290302
log.add("getSnapshotByTag:" + tagName);
291303
lastMvccTable = table;
292304
lastTagNameArg = tagName;
@@ -310,6 +322,7 @@ public java.util.Optional<PaimonCatalogOps.PaimonSchemaSnapshot> latestSchema(Ta
310322

311323
@Override
312324
public boolean branchExists(Table table, String branchName) {
325+
checkSnapshotAuthentication();
313326
log.add("branchExists:" + branchName);
314327
// Capture which table validation ran against (must be the BASE table, mirroring legacy
315328
// resolvePaimonBranch which validates the branch on the base table's branchManager).

‎regression-test/suites/external_table_p2/paimon/test_paimon_hms_catalog.groovy‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ suite("test_paimon_hms_catalog", "p2,external") {
5454
sql """SELECT * FROM external_test_table FOR VERSION AS OF 1;"""
5555
// branch/tag
5656
// TODO(zgx): add branch/tag
57+
// REFRESH must reload snapshots with the catalog identity, even after a warm query.
58+
def beforeRefresh = sql """SELECT * FROM external_test_table ORDER BY 1, 2"""
59+
sql """REFRESH TABLE external_test_table"""
60+
assertEquals(beforeRefresh, sql("""SELECT * FROM external_test_table ORDER BY 1, 2"""))
5761
// system table
5862
sql """SELECT * FROM external_test_table\$snapshots;"""
5963
}

0 commit comments

Comments
 (0)