From bc1041fa7fd83e276d1a43ded4daa17bd9e44ccc Mon Sep 17 00:00:00 2001 From: Henrib Date: Fri, 17 Apr 2026 20:03:35 +0200 Subject: [PATCH 01/20] HIVE-29035 : new version of cache that checks table location from Hive DB to ensure no stale table object is returned; --- .../apache/iceberg/hive/MetadataLocator.java | 95 ++++++++ .../iceberg/rest/HMSCachingCatalog.java | 218 ++++++++++++++++-- .../iceberg/rest/HMSCatalogAdapter.java | 16 +- .../rest/responses/HMSCacheStatsResponse.java | 36 +++ 4 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java new file mode 100644 index 000000000000..4dff80477a49 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.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.iceberg.hive; + +import org.apache.hadoop.hive.metastore.IMetaStoreClient; +import org.apache.hadoop.hive.metastore.api.GetProjectionsSpec; +import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; +import org.apache.hadoop.hive.metastore.api.Table; +import org.apache.hadoop.hive.metastore.client.builder.GetTableProjectionsSpecBuilder; +import org.apache.iceberg.BaseMetastoreTableOperations; +import org.apache.iceberg.ClientPool; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.exceptions.NoSuchTableException; +import org.apache.thrift.TException; + +import java.util.Collections; +import java.util.List; + +/** + * Fetches the location of a given metadata table. + *

Since the location mutates with each transaction, this allows determining if a cached version of the + * table is the latest known in the HMS database.

+ */ +public class MetadataLocator { + private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(MetadataLocator.class); + static final GetProjectionsSpec PARAM_SPEC = new GetTableProjectionsSpecBuilder() + .includeParameters() // only fetches table.parameters + .build(); + final HiveCatalog catalog; + + public MetadataLocator(HiveCatalog catalog) { + this.catalog = catalog; + } + + /** + * Returns the location of the metadata table identified by the given identifier, or null if the table does not exist or is not a metadata table. + *

This uses the Thrift API to fetch the table parameters, which is more efficient than fetching the entire table object.

+ * @param identifier the identifier of the metadata table to fetch the location for + * @return the location of the metadata table, or null if the table does not exist or is not a metadata table + */ + public String getLocation(TableIdentifier identifier) { + final ClientPool clients = catalog.clientPool(); + final String catName = catalog.name(); + final TableIdentifier baseTableIdentifier; + if (!catalog.isValidIdentifier(identifier)) { + if (!isValidMetadataIdentifier(identifier)) { + return null; + } else { + baseTableIdentifier = TableIdentifier.of(identifier.namespace().levels()); + } + } else { + baseTableIdentifier = identifier; + } + String database = baseTableIdentifier.namespace().level(0); + String tableName = baseTableIdentifier.name(); + try { + List tables = clients.run( + client -> client.getTables(catName, database, Collections.singletonList(tableName), PARAM_SPEC) + ); + return tables == null || tables.isEmpty() + ? null + : tables.getFirst().getParameters().get(BaseMetastoreTableOperations.METADATA_LOCATION_PROP); + } catch (NoSuchTableException | NoSuchObjectException e) { + LOGGER.info("Table not found {}", baseTableIdentifier, e); + } catch (TException e) { + LOGGER.info("Table parameters fetch failed {}", baseTableIdentifier, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.error("Interrupted in call to check table existence of {}", baseTableIdentifier, e); + } + return null; + } + + private boolean isValidMetadataIdentifier(TableIdentifier identifier) { + return MetadataTableType.from(identifier.name()) != null && catalog.isValidIdentifier(TableIdentifier.of(identifier.namespace().levels())); + } +} diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index a78bd6cfbf5d..cc1ada191d12 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -20,11 +20,21 @@ package org.apache.iceberg.rest; import com.github.benmanes.caffeine.cache.Ticker; + import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.hadoop.conf.Configuration; +import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.CachingCatalog; +import org.apache.iceberg.HasTableOperations; +import org.apache.iceberg.MetadataTableType; +import org.apache.iceberg.MetadataTableUtils; import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.TableOperations; import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.SupportsNamespaces; @@ -32,59 +42,222 @@ import org.apache.iceberg.catalog.ViewCatalog; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.hive.MetadataLocator; import org.apache.iceberg.view.View; import org.apache.iceberg.view.ViewBuilder; - +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Class that wraps an Iceberg Catalog to cache tables. */ public class HMSCachingCatalog extends CachingCatalog implements SupportsNamespaces, ViewCatalog { - private final HiveCatalog hiveCatalog; - - public HMSCachingCatalog(HiveCatalog catalog, long expiration) { - super(catalog, true, expiration, Ticker.systemTicker()); + protected static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); + + protected final HiveCatalog hiveCatalog; + // Metrics counters + private final AtomicLong cacheHitCount = new AtomicLong(0); + private final AtomicLong cacheMissCount = new AtomicLong(0); + private final AtomicLong cacheLoadCount = new AtomicLong(0); + private final AtomicLong cacheInvalidateCount = new AtomicLong(0); + private final AtomicLong cacheMetaLoadCount = new AtomicLong(0); + + public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { + this(catalog, expirationMs, /*caseSensitive*/ true, null); + } + + public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSensitive, Configuration conf) { + super(catalog, caseSensitive, expirationMs, Ticker.systemTicker()); this.hiveCatalog = catalog; } + /** + * Callback when cache invalidates the entry for a given table identifier. + * + * @param tid the table identifier to invalidate + */ + protected void onCacheInvalidate(TableIdentifier tid) { + cacheInvalidateCount.incrementAndGet(); + LOG.debug("Cache invalidate {}: {}", tid, cacheInvalidateCount.get()); + } + + /** + * Callback when cache loads a table for a given table identifier. + * + * @param tid the table identifier + */ + protected void onCacheLoad(TableIdentifier tid) { + cacheLoadCount.incrementAndGet(); + LOG.debug("Cache load {}: {}", tid, cacheLoadCount.get()); + } + + /** + * Callback when cache hit for a given table identifier. + * + * @param tid the table identifier + */ + protected void onCacheHit(TableIdentifier tid) { + cacheHitCount.incrementAndGet(); + LOG.debug("Cache hit {} : {}", tid, cacheHitCount.get()); + } + + /** + * Callback when cache miss occurs for a given table identifier. + * + * @param tid the table identifier + */ + protected void onCacheMiss(TableIdentifier tid) { + cacheMissCount.incrementAndGet(); + LOG.debug("Cache miss {}: {}", tid, cacheMissCount.get()); + } + + /** + * Callback when cache loads a metadata table for a given table identifier. + * + * @param tid the table identifier + */ + protected void onCacheMetaLoad(TableIdentifier tid) { + cacheMetaLoadCount.incrementAndGet(); + LOG.debug("Cache meta-load {}: {}", tid, cacheMetaLoadCount.get()); + } + + // Getter methods for accessing metrics + public long getCacheHitCount() { + return cacheHitCount.get(); + } + + public long getCacheMissCount() { + return cacheMissCount.get(); + } + + public long getCacheLoadCount() { + return cacheLoadCount.get(); + } + + public long getCacheInvalidateCount() { + return cacheInvalidateCount.get(); + } + + public long getCacheMetaLoadCount() { + return cacheMetaLoadCount.get(); + } + + public double getCacheHitRate() { + long hits = cacheHitCount.get(); + long total = hits + cacheMissCount.get(); + return total == 0 ? 0.0 : (double) hits / total; + } + + /** + * Generates a map of this cache's performance metrics, including hit count, + * miss count, load count, invalidate count, meta-load count, and hit rate. + * This can be used for monitoring and debugging purposes to understand the effectiveness of the cache. + * @return a map of cache performance metrics + */ + public Map cacheStats() { + return Map.of( + "hit", getCacheHitCount(), + "miss", getCacheMissCount(), + "load", getCacheLoadCount(), + "invalidate", getCacheInvalidateCount(), + "metaload", getCacheMetaLoadCount(), + "hit-rate", getCacheHitRate() + ); + } + + @Override - public Catalog.TableBuilder buildTable(TableIdentifier identifier, Schema schema) { - return hiveCatalog.buildTable(identifier, schema); + public void createNamespace(Namespace namespace, Map map) { + hiveCatalog.createNamespace(namespace, map); } @Override - public void createNamespace(Namespace nmspc, Map map) { - hiveCatalog.createNamespace(nmspc, map); + public List listNamespaces(Namespace namespace) throws NoSuchNamespaceException { + return hiveCatalog.listNamespaces(namespace); } @Override - public List listNamespaces(Namespace nmspc) throws NoSuchNamespaceException { - return hiveCatalog.listNamespaces(nmspc); + public Table loadTable(final TableIdentifier identifier) { + final TableIdentifier canonicalized = identifier.toLowerCase(); + final Table cachedTable = tableCache.getIfPresent(canonicalized); + if (cachedTable != null) { + final String location = new MetadataLocator(hiveCatalog).getLocation(canonicalized); + if (location == null) { + LOG.debug("Table {} has no location, returning cached table without location", canonicalized); + } else { + String cachedLocation = cachedTable instanceof HasTableOperations tableOps + ? tableOps.operations().current().metadataFileLocation() + : null; + if (!location.equals(cachedLocation)) { + LOG.debug("Invalidate table {}, cached {} != actual {}", canonicalized, cachedLocation, location); + // Invalidate the cached table if the location is different + invalidateTable(canonicalized); + onCacheInvalidate(canonicalized); + } else { + onCacheHit(canonicalized); + return cachedTable; + } + } + } else { + onCacheMiss(canonicalized); + } + final Table table = tableCache.get(canonicalized, this::loadTableWithoutCache); + if (table instanceof BaseMetadataTable) { + // Cache underlying table + TableIdentifier originTableIdentifier = + TableIdentifier.of(canonicalized.namespace().levels()); + Table originTable = tableCache.get(originTableIdentifier, this::loadTableWithoutCache); + // Share TableOperations instance of origin table for all metadata tables, so that metadata + // table instances are refreshed as well when origin table instance is refreshed. + if (originTable instanceof HasTableOperations tableOps) { + TableOperations ops = tableOps.operations(); + MetadataTableType type = MetadataTableType.from(canonicalized.name()); + Table metadataTable = + MetadataTableUtils.createMetadataTableInstance( + ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); + tableCache.put(canonicalized, metadataTable); + onCacheMetaLoad(canonicalized); + LOG.debug("Loaded metadata table: {} for origin table: {}", canonicalized, originTableIdentifier); + // Return the metadata table instead of the original table + return metadataTable; + } + } + onCacheLoad(canonicalized); + return table; + } + + private Table loadTableWithoutCache(TableIdentifier identifier) { + try { + return hiveCatalog.loadTable(identifier); + } catch (NoSuchTableException exception) { + return null; + } } @Override - public Map loadNamespaceMetadata(Namespace nmspc) throws NoSuchNamespaceException { - return hiveCatalog.loadNamespaceMetadata(nmspc); + public Map loadNamespaceMetadata(Namespace namespace) throws NoSuchNamespaceException { + return hiveCatalog.loadNamespaceMetadata(namespace); } @Override - public boolean dropNamespace(Namespace nmspc) throws NamespaceNotEmptyException { - List tables = listTables(nmspc); + public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyException { + List tables = listTables(namespace); for (TableIdentifier ident : tables) { invalidateTable(ident); } - return hiveCatalog.dropNamespace(nmspc); + return hiveCatalog.dropNamespace(namespace); } @Override - public boolean setProperties(Namespace nmspc, Map map) throws NoSuchNamespaceException { - return hiveCatalog.setProperties(nmspc, map); + public boolean setProperties(Namespace namespace, Map map) throws NoSuchNamespaceException { + return hiveCatalog.setProperties(namespace, map); } @Override - public boolean removeProperties(Namespace nmspc, Set set) throws NoSuchNamespaceException { - return hiveCatalog.removeProperties(nmspc, set); + public boolean removeProperties(Namespace namespace, Set set) throws NoSuchNamespaceException { + return hiveCatalog.removeProperties(namespace, set); } @Override @@ -92,6 +265,11 @@ public boolean namespaceExists(Namespace namespace) { return hiveCatalog.namespaceExists(namespace); } + @Override + public Catalog.TableBuilder buildTable(TableIdentifier identifier, Schema schema) { + return hiveCatalog.buildTable(identifier, schema); + } + @Override public List listViews(Namespace namespace) { return hiveCatalog.listViews(namespace); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java index 885e30063528..96482df632f2 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.time.Clock; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletResponse; @@ -75,6 +76,7 @@ import org.apache.iceberg.rest.responses.ListTablesResponse; import org.apache.iceberg.rest.responses.LoadTableResponse; import org.apache.iceberg.rest.responses.LoadViewResponse; +import org.apache.iceberg.rest.responses.HMSCacheStatsResponse; import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse; import org.apache.iceberg.util.Pair; import org.apache.iceberg.util.PropertyUtil; @@ -228,6 +230,14 @@ public Class requestClass() { } } + private HMSCacheStatsResponse cacheStats() { + Map stats = Collections.emptyMap(); + if (catalog instanceof HMSCachingCatalog hmsCatalog) { + stats = hmsCatalog.cacheStats(); + } + return castResponse(HMSCacheStatsResponse.class, new HMSCacheStatsResponse(stats)); + } + private ConfigResponse config() { final List endpoints = Arrays.stream(Route.values()) .map(r -> Endpoint.create(r.method.name(), r.resourcePath)).toList(); @@ -489,7 +499,7 @@ private T handleRequest( case COMMIT_TRANSACTION: return (T) commitTransaction(body); - + case LIST_VIEWS: return (T) listViews(vars); @@ -504,10 +514,10 @@ private T handleRequest( case UPDATE_VIEW: return (T) updateView(vars, body); - + case RENAME_VIEW: return (T) renameView(body); - + case DROP_VIEW: return (T) dropView(vars); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java new file mode 100644 index 000000000000..1424010fd693 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.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.iceberg.rest.responses; + +import org.apache.iceberg.rest.RESTResponse; + +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; + +public record HMSCacheStatsResponse(Map stats) implements RESTResponse { + public HMSCacheStatsResponse(Map stats) { + this.stats = Collections.unmodifiableMap(new TreeMap<>(stats)); + } + + @Override + public void validate() { + // nothing + } +} From ab2c59f9b6bedb62c0b1a6d7d39a964e346cff2d Mon Sep 17 00:00:00 2001 From: Henrib Date: Fri, 17 Apr 2026 23:32:57 +0200 Subject: [PATCH 02/20] HIVE-29035 : added the forgotten test; - improved check; - quiesce console logs due to internal throws in servlet; --- .../iceberg/rest/HMSCachingCatalog.java | 21 ++ .../iceberg/rest/HMSCatalogServlet.java | 7 + .../rest/TestHMSCachingCatalogStats.java | 196 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index cc1ada191d12..303427477abe 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -21,10 +21,12 @@ import com.github.benmanes.caffeine.cache.Ticker; +import java.lang.ref.SoftReference; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.BaseMetadataTable; @@ -47,6 +49,7 @@ import org.apache.iceberg.hive.MetadataLocator; import org.apache.iceberg.view.View; import org.apache.iceberg.view.ViewBuilder; +import org.jetbrains.annotations.TestOnly; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +59,21 @@ public class HMSCachingCatalog extends CachingCatalog implements SupportsNamespaces, ViewCatalog { protected static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); + private static SoftReference CACHE = new SoftReference<>(null); + @TestOnly + public static C getLatestCache(Function extractor) { + HMSCachingCatalog cache = CACHE.get(); + if (cache == null) { + return null; + } + return extractor == null ? (C) cache : extractor.apply(cache); + } + + @TestOnly + public HiveCatalog getCatalog() { + return hiveCatalog; + } + protected final HiveCatalog hiveCatalog; // Metrics counters private final AtomicLong cacheHitCount = new AtomicLong(0); @@ -71,6 +89,9 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSensitive, Configuration conf) { super(catalog, caseSensitive, expirationMs, Ticker.systemTicker()); this.hiveCatalog = catalog; + if (catalog.getConf().getBoolean("metastore.iceberg.catalog.cache.debug", false)) { + CACHE = new SoftReference<>(this); + } } /** diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java index 92cd4af484a1..081151090ab5 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java @@ -29,6 +29,7 @@ import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; import org.apache.iceberg.rest.HMSCatalogAdapter.Route; import org.apache.iceberg.rest.HTTPRequest.HTTPMethod; +import org.apache.iceberg.exceptions.RESTException; import org.apache.iceberg.rest.responses.ErrorResponse; import org.apache.iceberg.util.Pair; import org.slf4j.Logger; @@ -80,7 +81,13 @@ protected void service(HttpServletRequest request, HttpServletResponse response) if (responseBody != null) { RESTObjectMapper.mapper().writeValue(response.getWriter(), responseBody); } + } catch (RESTException e) { + // A RESTException is thrown by HMSCatalogAdapter.execute() after the error handler has + // already written the correct HTTP status and body to the response (e.g. 404, 403). + // It is not an unexpected server failure, so log at DEBUG to avoid flooding the console. + LOG.debug("REST request resulted in a client error (already handled): {}", e.getMessage()); } catch (RuntimeException | IOException e) { + // Genuine unexpected server error – log the full stack trace. LOG.error("Error processing REST request", e); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java new file mode 100644 index 000000000000..f1b56e0e68e0 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.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.iceberg.rest; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; +import org.apache.hadoop.hive.metastore.annotation.MetastoreCheckinTest; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +import org.apache.iceberg.PartitionSpec; +import org.apache.iceberg.Schema; +import org.apache.iceberg.Table; +import org.apache.iceberg.catalog.Catalog; +import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension; +import org.apache.iceberg.rest.responses.HMSCacheStatsResponse; +import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Integration tests that verify the {@link HMSCachingCatalog} cache-statistics counters + * (hit, miss, load, hit-rate) are updated correctly and exposed accurately via the + * {@code GET v1/cache/stats} REST endpoint. + * + *

The server is started with {@link AuthType#NONE} so the tests focus purely on + * caching behaviour without any authentication noise. + */ +@Category(MetastoreCheckinTest.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TestHMSCachingCatalogStats { + + /** 5 minutes expressed in milliseconds – the value injected into {@code ICEBERG_CATALOG_CACHE_EXPIRY}. */ + private static final long CACHE_EXPIRY_MS = 5 * 60 * 1_000L; + + @RegisterExtension + private static final HiveRESTCatalogServerExtension REST_CATALOG_EXTENSION = + HiveRESTCatalogServerExtension.builder(AuthType.NONE) + // Without a positive expiry the HMSCatalogFactory skips HMSCachingCatalog entirely. + .configure( + MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), + String.valueOf(CACHE_EXPIRY_MS)) + .configure("metastore.iceberg.catalog.cache.debug", "true") + .build(); + + private RESTCatalog catalog; + private HiveCatalog serverCatalog; + + @BeforeAll + void setupAll() { + catalog = RCKUtils.initCatalogClient(clientConfig()); + serverCatalog = HMSCachingCatalog.getLatestCache(HMSCachingCatalog::getCatalog); + } + + /** Remove any namespace/table created by the test so each run starts clean. */ + @AfterEach + void cleanup() { + RCKUtils.purgeCatalogTestEntries(catalog); + } + + // --------------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------------- + + private java.util.Map clientConfig() { + return java.util.Map.of("uri", REST_CATALOG_EXTENSION.getRestEndpoint()); + } + + /** + * Calls the {@code GET v1/cache/stats} endpoint directly over HTTP and returns + * the deserialised {@link HMSCacheStatsResponse}. + */ + private HMSCacheStatsResponse fetchCacheStats() throws Exception { + String statsUrl = REST_CATALOG_EXTENSION.getRestEndpoint() + "/v1/cache/stats"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(statsUrl)) + .GET() + .build(); + HttpResponse response; + try (HttpClient client = HttpClient.newHttpClient()) { + response = client.send(request, HttpResponse.BodyHandlers.ofString()); + } + Assertions.assertEquals(200, response.statusCode(), + "Expected HTTP 200 from cache stats endpoint, got: " + response.statusCode()); + return new ObjectMapper().readValue(response.body(), HMSCacheStatsResponse.class); + } + + + /** + * Verifies that the {@link HMSCachingCatalog} correctly tracks cache hits, misses and + * loads, and that those counters are accurately returned via the REST endpoint. + * + *

Strategy: + *

    + *
  1. Snapshot baseline stats before any operations so the test is isolated from + * cumulative counters left by previous tests.
  2. + *
  3. Create a namespace and a table (bypasses the cache – done via + * {@link org.apache.iceberg.hive.HiveCatalog} directly).
  4. + *
  5. First {@code loadTable} call → cache miss + actual load.
  6. + *
  7. Second and third {@code loadTable} calls → cache hits (metadata location + * has not changed, so the cached entry is still valid).
  8. + *
  9. Fetch stats again and assert the deltas against the baseline.
  10. + *
+ */ + @Test + void testCacheCountersAreUpdated() throws Exception { + // -- baseline --------------------------------------------------------------- + HMSCacheStatsResponse baseline = fetchCacheStats(); + long baseHit = baseline.stats().getOrDefault("hit", 0L).longValue(); + long baseMiss = baseline.stats().getOrDefault("miss", 0L).longValue(); + long baseLoad = baseline.stats().getOrDefault("load", 0L).longValue(); + + // -- exercise the cache ----------------------------------------------------- + var db = Namespace.of("caching_stats_test_db"); + var tableId = TableIdentifier.of(db, "caching_stats_test_table"); + + catalog.createNamespace(db); + catalog.createTable(tableId, new Schema()); + + // First load → cache miss + load + catalog.loadTable(tableId); + // Second load → cache hit (metadata location unchanged) + catalog.loadTable(tableId); + // Third load → cache hit + catalog.loadTable(tableId); + + // Mutate the table by appending a data file – this creates a new snapshot + // which advances METADATA_LOCATION in HMS, so the next loadTable call through + // the caching catalog will detect the stale cached location and invalidate it. + Table table = serverCatalog.loadTable(tableId); + DataFile dataFile = DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(table.location() + "/data/fake-0.parquet") + .withFileSizeInBytes(1024) + .withRecordCount(1) + .build(); + table.newAppend() + .appendFile(dataFile) + .commit(); + + long baseInvalidate = fetchCacheStats().stats().getOrDefault("invalidate", 0L).longValue(); + + // Fourth load → cache invalidation + load (cached location != HMS location) + catalog.loadTable(tableId); + + // -- fetch updated stats via the REST endpoint ------------------------------ + HMSCacheStatsResponse after = fetchCacheStats(); + long deltaHit = after.stats().getOrDefault("hit", 0L).longValue() - baseHit; + long deltaMiss = after.stats().getOrDefault("miss", 0L).longValue() - baseMiss; + long deltaLoad = after.stats().getOrDefault("load", 0L).longValue() - baseLoad; + long deltaInvalidate = after.stats().getOrDefault("invalidate", 0L).longValue() - baseInvalidate; + + // -- assertions ------------------------------------------------------------- + Assertions.assertTrue(deltaMiss >= 1, + "Expected at least 1 cache miss (first loadTable), but delta was: " + deltaMiss); + Assertions.assertTrue(deltaLoad >= 2, + "Expected at least 2 cache loads (initial load + post-invalidation reload), but delta was: " + deltaLoad); + Assertions.assertTrue(deltaHit >= 2, + "Expected at least 2 cache hits (second + third loadTable), but delta was: " + deltaHit); + Assertions.assertTrue(deltaInvalidate >= 1, + "Expected at least 1 cache invalidation (metadata location changed after table update), but delta was: " + deltaInvalidate); + + // hit-rate must be a valid ratio in [0.0, 1.0] + double hitRate = after.stats().getOrDefault("hit-rate", 0.0).doubleValue(); + Assertions.assertTrue(hitRate >= 0.0 && hitRate <= 1.0, + "hit-rate must be in [0.0, 1.0] but was: " + hitRate); + } +} + From a16428becea7e1258e0bb5d45b833ee2564362fb Mon Sep 17 00:00:00 2001 From: Henrib Date: Tue, 28 Apr 2026 18:32:22 +0200 Subject: [PATCH 03/20] HIVE-29035 : rebased and updated review nits; --- .../org/apache/iceberg/hive/MetadataLocator.java | 12 +++++++++--- .../org/apache/iceberg/rest/HMSCachingCatalog.java | 13 ++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index 4dff80477a49..588818cb456a 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -44,14 +44,19 @@ public class MetadataLocator { static final GetProjectionsSpec PARAM_SPEC = new GetTableProjectionsSpecBuilder() .includeParameters() // only fetches table.parameters .build(); - final HiveCatalog catalog; + private final HiveCatalog catalog; public MetadataLocator(HiveCatalog catalog) { this.catalog = catalog; } + public HiveCatalog getCatalog() { + return catalog; + } + /** - * Returns the location of the metadata table identified by the given identifier, or null if the table does not exist or is not a metadata table. + * Returns the location of the metadata table identified by the given identifier, or null if the table does not exist or is + * not a metadata table. *

This uses the Thrift API to fetch the table parameters, which is more efficient than fetching the entire table object.

* @param identifier the identifier of the metadata table to fetch the location for * @return the location of the metadata table, or null if the table does not exist or is not a metadata table @@ -90,6 +95,7 @@ public String getLocation(TableIdentifier identifier) { } private boolean isValidMetadataIdentifier(TableIdentifier identifier) { - return MetadataTableType.from(identifier.name()) != null && catalog.isValidIdentifier(TableIdentifier.of(identifier.namespace().levels())); + return MetadataTableType.from(identifier.name()) != null + && catalog.isValidIdentifier(TableIdentifier.of(identifier.namespace().levels())); } } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 303427477abe..6ef07a856fad 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -28,7 +28,6 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; -import org.apache.hadoop.conf.Configuration; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.CachingCatalog; import org.apache.iceberg.HasTableOperations; @@ -59,10 +58,10 @@ public class HMSCachingCatalog extends CachingCatalog implements SupportsNamespaces, ViewCatalog { protected static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); - private static SoftReference CACHE = new SoftReference<>(null); + private static SoftReference cacheRef = new SoftReference<>(null); @TestOnly public static C getLatestCache(Function extractor) { - HMSCachingCatalog cache = CACHE.get(); + HMSCachingCatalog cache = cacheRef.get(); if (cache == null) { return null; } @@ -74,7 +73,7 @@ public HiveCatalog getCatalog() { return hiveCatalog; } - protected final HiveCatalog hiveCatalog; + private final HiveCatalog hiveCatalog; // Metrics counters private final AtomicLong cacheHitCount = new AtomicLong(0); private final AtomicLong cacheMissCount = new AtomicLong(0); @@ -83,14 +82,14 @@ public HiveCatalog getCatalog() { private final AtomicLong cacheMetaLoadCount = new AtomicLong(0); public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { - this(catalog, expirationMs, /*caseSensitive*/ true, null); + this(catalog, expirationMs, /*caseSensitive*/ true); } - public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSensitive, Configuration conf) { + public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSensitive) { super(catalog, caseSensitive, expirationMs, Ticker.systemTicker()); this.hiveCatalog = catalog; if (catalog.getConf().getBoolean("metastore.iceberg.catalog.cache.debug", false)) { - CACHE = new SoftReference<>(this); + cacheRef = new SoftReference<>(this); } } From 6de4ed41d9ef3ec965743e97e5ac8b05a925bc08 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sat, 2 May 2026 14:17:10 +0200 Subject: [PATCH 04/20] HIVE-29035: improve error handling and logging in MetadataLocator and HMSCachingCatalog; - added l1 cache (default 3s / 32 entries) to reduce the latency for repeated access to the same table; - fix license header and addressed review comments; --- .../apache/iceberg/hive/MetadataLocator.java | 26 +-- .../iceberg/rest/HMSCachingCatalog.java | 169 +++++++++++++----- .../iceberg/rest/HMSCatalogAdapter.java | 18 +- .../iceberg/rest/HMSCatalogFactory.java | 12 +- .../iceberg/rest/HMSCatalogServlet.java | 11 +- .../rest/responses/HMSCacheStatsResponse.java | 14 +- .../rest/TestHMSCachingCatalogStats.java | 27 +-- 7 files changed, 176 insertions(+), 101 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index 588818cb456a..e6cc77416f5e 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -7,14 +7,13 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package org.apache.iceberg.hive; @@ -83,15 +82,18 @@ public String getLocation(TableIdentifier identifier) { return tables == null || tables.isEmpty() ? null : tables.getFirst().getParameters().get(BaseMetastoreTableOperations.METADATA_LOCATION_PROP); - } catch (NoSuchTableException | NoSuchObjectException e) { - LOGGER.info("Table not found {}", baseTableIdentifier, e); + } catch (NoSuchTableException e) { + LOGGER.debug("Table {} not found: {}", baseTableIdentifier, e.getMessage()); + throw e; + } catch (NoSuchObjectException e) { + throw new NoSuchTableException("Table {} not found: {}", baseTableIdentifier, e.getMessage()); } catch (TException e) { - LOGGER.info("Table parameters fetch failed {}", baseTableIdentifier, e); + LOGGER.info("Table {} parameters fetch failed: {}", baseTableIdentifier, e.getMessage()); + throw new RuntimeException("Failed to fetch table parameters for " + baseTableIdentifier, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - LOGGER.error("Interrupted in call to check table existence of {}", baseTableIdentifier, e); + throw new RuntimeException("Interrupted while fetching table parameters for " + baseTableIdentifier, e); } - return null; } private boolean isValidMetadataIdentifier(TableIdentifier identifier) { diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 6ef07a856fad..74dfb4d97aa6 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -9,12 +9,11 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package org.apache.iceberg.rest; @@ -22,12 +21,16 @@ import com.github.benmanes.caffeine.cache.Ticker; import java.lang.ref.SoftReference; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.conf.HiveConf; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.CachingCatalog; import org.apache.iceberg.HasTableOperations; @@ -58,8 +61,10 @@ public class HMSCachingCatalog extends CachingCatalog implements SupportsNamespaces, ViewCatalog { protected static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); - private static SoftReference cacheRef = new SoftReference<>(null); @TestOnly + private static SoftReference cacheRef = new SoftReference<>(null); + + @TestOnly @SuppressWarnings("unchecked") public static C getLatestCache(Function extractor) { HMSCachingCatalog cache = cacheRef.get(); if (cache == null) { @@ -73,8 +78,24 @@ public HiveCatalog getCatalog() { return hiveCatalog; } + // The underlying HiveCatalog instance. private final HiveCatalog hiveCatalog; - // Metrics counters + // Duplicate because CachingCatalog doesn't expose the case sensitivity of the underlying catalog, + // which is needed for canonicalizing identifiers before caching. + private final boolean caseSensitive; + // The locator. + private final MetadataLocator metadataLocator; + // An L1 small latency cache. + // This is used to cache the last cached time for each table identifier, + // so that we can skip location check for repeated access to the same table within a short period of time, + // which can significantly reduce the latency for repeated access to the same table. + private final Map l1Cache; + // The TTL for L1 cache (3s). + private final int L1TTL_MS; + // The L1 cache size. + private final int L1_CACHE_SIZE; + + // Metrics counters. private final AtomicLong cacheHitCount = new AtomicLong(0); private final AtomicLong cacheMissCount = new AtomicLong(0); private final AtomicLong cacheLoadCount = new AtomicLong(0); @@ -88,9 +109,29 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSensitive) { super(catalog, caseSensitive, expirationMs, Ticker.systemTicker()); this.hiveCatalog = catalog; - if (catalog.getConf().getBoolean("metastore.iceberg.catalog.cache.debug", false)) { + this.caseSensitive = caseSensitive; + this.metadataLocator = new MetadataLocator(catalog); + Configuration conf = catalog.getConf(); + if (HiveConf.getBoolVar(conf, HiveConf.ConfVars.HIVE_IN_TEST)) { + // Only keep a reference to the latest cache for testing purpose, so that tests can manipulate the catalog. cacheRef = new SoftReference<>(this); } + int l1size = conf.getInt("hms.caching.catalog.l1.cache.size", 32); + int l1ttl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); + if (l1size > 0 && l1ttl > 0) { + l1Cache = Collections.synchronizedMap(new LinkedHashMap() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > L1_CACHE_SIZE; + } + }); + L1TTL_MS = l1ttl; + L1_CACHE_SIZE = l1size; + } else { + l1Cache = Collections.emptyMap(); + L1TTL_MS = 0; + L1_CACHE_SIZE = 0; + } } /** @@ -99,8 +140,8 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSen * @param tid the table identifier to invalidate */ protected void onCacheInvalidate(TableIdentifier tid) { - cacheInvalidateCount.incrementAndGet(); - LOG.debug("Cache invalidate {}: {}", tid, cacheInvalidateCount.get()); + long count = cacheInvalidateCount.incrementAndGet(); + LOG.debug("Cache invalidate {}: {}", tid, count); } /** @@ -109,8 +150,8 @@ protected void onCacheInvalidate(TableIdentifier tid) { * @param tid the table identifier */ protected void onCacheLoad(TableIdentifier tid) { - cacheLoadCount.incrementAndGet(); - LOG.debug("Cache load {}: {}", tid, cacheLoadCount.get()); + long count = cacheLoadCount.incrementAndGet(); + LOG.debug("Cache load {}: {}", tid, count); } /** @@ -119,8 +160,8 @@ protected void onCacheLoad(TableIdentifier tid) { * @param tid the table identifier */ protected void onCacheHit(TableIdentifier tid) { - cacheHitCount.incrementAndGet(); - LOG.debug("Cache hit {} : {}", tid, cacheHitCount.get()); + long count = cacheHitCount.incrementAndGet(); + LOG.debug("Cache hit {} : {}", tid, count); } /** @@ -129,8 +170,8 @@ protected void onCacheHit(TableIdentifier tid) { * @param tid the table identifier */ protected void onCacheMiss(TableIdentifier tid) { - cacheMissCount.incrementAndGet(); - LOG.debug("Cache miss {}: {}", tid, cacheMissCount.get()); + long count = cacheMissCount.incrementAndGet(); + LOG.debug("Cache miss {}: {}", tid, count); } /** @@ -139,8 +180,8 @@ protected void onCacheMiss(TableIdentifier tid) { * @param tid the table identifier */ protected void onCacheMetaLoad(TableIdentifier tid) { - cacheMetaLoadCount.incrementAndGet(); - LOG.debug("Cache meta-load {}: {}", tid, cacheMetaLoadCount.get()); + long count = cacheMetaLoadCount.incrementAndGet(); + LOG.debug("Cache meta-load {}: {}", tid, count); } // Getter methods for accessing metrics @@ -198,50 +239,86 @@ public List listNamespaces(Namespace namespace) throws NoSuchNamespac return hiveCatalog.listNamespaces(namespace); } + /** + * Canonicalizes the given table identifier based on the case sensitivity of the underlying catalog. + * Copied from CachingCatalog that exposes it as private. + * @param tableIdentifier the table identifier to canonicalize + * @return the canonicalized table identifier + */ + private TableIdentifier canonicalizeIdentifier(TableIdentifier tableIdentifier) { + return this.caseSensitive ? tableIdentifier : tableIdentifier.toLowerCase(); + } + + @Override + public void invalidateTable(TableIdentifier ident) { + super.invalidateTable(ident); + l1Cache.remove(ident); + } + @Override public Table loadTable(final TableIdentifier identifier) { - final TableIdentifier canonicalized = identifier.toLowerCase(); + final TableIdentifier canonicalized = canonicalizeIdentifier(identifier); final Table cachedTable = tableCache.getIfPresent(canonicalized); + long now = System.currentTimeMillis(); if (cachedTable != null) { - final String location = new MetadataLocator(hiveCatalog).getLocation(canonicalized); - if (location == null) { - LOG.debug("Table {} has no location, returning cached table without location", canonicalized); - } else { - String cachedLocation = cachedTable instanceof HasTableOperations tableOps - ? tableOps.operations().current().metadataFileLocation() - : null; - if (!location.equals(cachedLocation)) { - LOG.debug("Invalidate table {}, cached {} != actual {}", canonicalized, cachedLocation, location); - // Invalidate the cached table if the location is different - invalidateTable(canonicalized); - onCacheInvalidate(canonicalized); - } else { + // Determine if L1 cache is valid based on the last cached time and the TTL. + // If the table is in L1 cache, we can skip the location check and return the cached table directly, + // which can significantly reduce the latency for repeated access to the same table. + Long lastCached = l1Cache.get(canonicalized); + if (lastCached != null) { + if (now - lastCached < L1TTL_MS) { + LOG.debug("Table {} is in L1 cache, returning cached table", canonicalized); onCacheHit(canonicalized); return cachedTable; + } else { + l1Cache.remove(canonicalized); } } + // If the table is no longer in L1 cache, we need to check the location. + final String location = metadataLocator.getLocation(canonicalized); + if (location == null) { + LOG.debug("Table {} has no location, returning cached table without location", canonicalized); + onCacheHit(canonicalized); + l1Cache.put(canonicalized, now); + return cachedTable; + } + String cachedLocation = cachedTable instanceof HasTableOperations tableOps + ? tableOps.operations().current().metadataFileLocation() + : null; + if (location.equals(cachedLocation)) { + onCacheHit(canonicalized); + l1Cache.put(canonicalized, now); + return cachedTable; + } else { + LOG.debug("Invalidate table {}, cached {} != actual {}", canonicalized, cachedLocation, location); + // Invalidate the cached table if the location is different + invalidateTable(canonicalized); + onCacheInvalidate(canonicalized); + } } else { onCacheMiss(canonicalized); } + // The following code is copied from CachingCatalog.loadTable(), but with additional handling for L1 cache and stats. final Table table = tableCache.get(canonicalized, this::loadTableWithoutCache); if (table instanceof BaseMetadataTable) { - // Cache underlying table - TableIdentifier originTableIdentifier = - TableIdentifier.of(canonicalized.namespace().levels()); + // Cache underlying table: there must be a table named by the namespace (?) + TableIdentifier originTableIdentifier = TableIdentifier.of(canonicalized.namespace().levels()); Table originTable = tableCache.get(originTableIdentifier, this::loadTableWithoutCache); // Share TableOperations instance of origin table for all metadata tables, so that metadata // table instances are refreshed as well when origin table instance is refreshed. if (originTable instanceof HasTableOperations tableOps) { TableOperations ops = tableOps.operations(); MetadataTableType type = MetadataTableType.from(canonicalized.name()); - Table metadataTable = - MetadataTableUtils.createMetadataTableInstance( - ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); - tableCache.put(canonicalized, metadataTable); - onCacheMetaLoad(canonicalized); - LOG.debug("Loaded metadata table: {} for origin table: {}", canonicalized, originTableIdentifier); - // Return the metadata table instead of the original table - return metadataTable; + // Defensive: CachingCatalog doesn't perform this check + if (type != null) { + Table metadataTable = MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); + tableCache.put(canonicalized, metadataTable); + l1Cache.put(canonicalized, now); + onCacheMetaLoad(canonicalized); + LOG.debug("Loaded metadata table: {} for origin table: {}", canonicalized, originTableIdentifier); + // Return the metadata table instead of the original table + return metadataTable; + } } } onCacheLoad(canonicalized); @@ -249,11 +326,7 @@ public Table loadTable(final TableIdentifier identifier) { } private Table loadTableWithoutCache(TableIdentifier identifier) { - try { return hiveCatalog.loadTable(identifier); - } catch (NoSuchTableException exception) { - return null; - } } @Override diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java index 96482df632f2..e3dae744ba71 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java @@ -9,12 +9,11 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package org.apache.iceberg.rest; @@ -426,10 +425,9 @@ private static void commitTransaction(Catalog catalog, CommitTransactionRequest for (UpdateTableRequest tableChange : request.tableChanges()) { Table table = catalog.loadTable(tableChange.identifier()); - if (table instanceof BaseTable) { - Transaction transaction = - Transactions.newTransaction( - tableChange.identifier().toString(), ((BaseTable) table).operations()); + if (table instanceof BaseTable baseTable) { + Transaction transaction = Transactions.newTransaction( + tableChange.identifier().toString(), baseTable.operations()); transactions.add(transaction); BaseTransaction.TransactionTable txTable = diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java index 6f1694246261..f772ce85a7da 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java @@ -9,13 +9,13 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ + package org.apache.iceberg.rest; import java.lang.reflect.InvocationTargetException; diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java index 081151090ab5..ef4245b7be21 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java @@ -9,12 +9,11 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package org.apache.iceberg.rest; diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java index 1424010fd693..9e3fa6c164b7 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java @@ -7,15 +7,15 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ + package org.apache.iceberg.rest.responses; import org.apache.iceberg.rest.RESTResponse; diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index f1b56e0e68e0..facd7ba26afa 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -24,6 +24,8 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Map; + import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; import org.apache.hadoop.hive.metastore.annotation.MetastoreCheckinTest; import org.apache.hadoop.hive.metastore.conf.MetastoreConf; @@ -68,7 +70,7 @@ class TestHMSCachingCatalogStats { .configure( MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), String.valueOf(CACHE_EXPIRY_MS)) - .configure("metastore.iceberg.catalog.cache.debug", "true") + .configure("hive.in.test", "true") .build(); private RESTCatalog catalog; @@ -133,10 +135,10 @@ private HMSCacheStatsResponse fetchCacheStats() throws Exception { @Test void testCacheCountersAreUpdated() throws Exception { // -- baseline --------------------------------------------------------------- - HMSCacheStatsResponse baseline = fetchCacheStats(); - long baseHit = baseline.stats().getOrDefault("hit", 0L).longValue(); - long baseMiss = baseline.stats().getOrDefault("miss", 0L).longValue(); - long baseLoad = baseline.stats().getOrDefault("load", 0L).longValue(); + Map baseline = fetchCacheStats().stats(); + long baseHit = baseline.getOrDefault("hit", 0L).longValue(); + long baseMiss = baseline.getOrDefault("miss", 0L).longValue(); + long baseLoad = baseline.getOrDefault("load", 0L).longValue(); // -- exercise the cache ----------------------------------------------------- var db = Namespace.of("caching_stats_test_db"); @@ -166,16 +168,17 @@ void testCacheCountersAreUpdated() throws Exception { .commit(); long baseInvalidate = fetchCacheStats().stats().getOrDefault("invalidate", 0L).longValue(); - + // the L1 cache has a 3 seconds default delay before it considers entries stale + Thread.sleep(3_000); // Fourth load → cache invalidation + load (cached location != HMS location) catalog.loadTable(tableId); // -- fetch updated stats via the REST endpoint ------------------------------ - HMSCacheStatsResponse after = fetchCacheStats(); - long deltaHit = after.stats().getOrDefault("hit", 0L).longValue() - baseHit; - long deltaMiss = after.stats().getOrDefault("miss", 0L).longValue() - baseMiss; - long deltaLoad = after.stats().getOrDefault("load", 0L).longValue() - baseLoad; - long deltaInvalidate = after.stats().getOrDefault("invalidate", 0L).longValue() - baseInvalidate; + Map after = fetchCacheStats().stats(); + long deltaHit = after.getOrDefault("hit", 0L).longValue() - baseHit; + long deltaMiss = after.getOrDefault("miss", 0L).longValue() - baseMiss; + long deltaLoad = after.getOrDefault("load", 0L).longValue() - baseLoad; + long deltaInvalidate = after.getOrDefault("invalidate", 0L).longValue() - baseInvalidate; // -- assertions ------------------------------------------------------------- Assertions.assertTrue(deltaMiss >= 1, @@ -189,7 +192,7 @@ void testCacheCountersAreUpdated() throws Exception { // hit-rate must be a valid ratio in [0.0, 1.0] double hitRate = after.stats().getOrDefault("hit-rate", 0.0).doubleValue(); - Assertions.assertTrue(hitRate >= 0.0 && hitRate <= 1.0, + Assertions.assertTrue(hitRate > 0.0 && hitRate <= 1.0, "hit-rate must be in [0.0, 1.0] but was: " + hitRate); } } From 528cbdd3af5dd1a3795f1ffb9eb0f14482ba94e8 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sat, 2 May 2026 15:04:10 +0200 Subject: [PATCH 05/20] HIVE-29035: fix test; --- .../org/apache/iceberg/rest/TestHMSCachingCatalogStats.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index facd7ba26afa..2d9086fdf408 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -191,7 +191,7 @@ void testCacheCountersAreUpdated() throws Exception { "Expected at least 1 cache invalidation (metadata location changed after table update), but delta was: " + deltaInvalidate); // hit-rate must be a valid ratio in [0.0, 1.0] - double hitRate = after.stats().getOrDefault("hit-rate", 0.0).doubleValue(); + double hitRate = after.getOrDefault("hit-rate", 0.0).doubleValue(); Assertions.assertTrue(hitRate > 0.0 && hitRate <= 1.0, "hit-rate must be in [0.0, 1.0] but was: " + hitRate); } From e5639b3d4a503b91f21d9b1852aa95783932ff89 Mon Sep 17 00:00:00 2001 From: Henrib Date: Mon, 4 May 2026 15:31:22 +0200 Subject: [PATCH 06/20] HIVE-29035 : enhance error handling and improve variable naming in caching components --- .../apache/iceberg/hive/MetadataLocator.java | 7 ++++--- .../apache/iceberg/rest/HMSCachingCatalog.java | 18 +++++++++--------- .../rest/responses/HMSCacheStatsResponse.java | 4 +++- .../rest/TestHMSCachingCatalogStats.java | 2 +- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index e6cc77416f5e..1a90111452f9 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -40,7 +40,7 @@ */ public class MetadataLocator { private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(MetadataLocator.class); - static final GetProjectionsSpec PARAM_SPEC = new GetTableProjectionsSpecBuilder() + private static final GetProjectionsSpec PARAM_SPEC = new GetTableProjectionsSpecBuilder() .includeParameters() // only fetches table.parameters .build(); private final HiveCatalog catalog; @@ -54,11 +54,12 @@ public HiveCatalog getCatalog() { } /** - * Returns the location of the metadata table identified by the given identifier, or null if the table does not exist or is + * Returns the location of the metadata table identified by the given identifier, or null if the table is * not a metadata table. *

This uses the Thrift API to fetch the table parameters, which is more efficient than fetching the entire table object.

* @param identifier the identifier of the metadata table to fetch the location for * @return the location of the metadata table, or null if the table does not exist or is not a metadata table + * @throws NoSuchTableException if the table does not exist */ public String getLocation(TableIdentifier identifier) { final ClientPool clients = catalog.clientPool(); @@ -86,7 +87,7 @@ public String getLocation(TableIdentifier identifier) { LOGGER.debug("Table {} not found: {}", baseTableIdentifier, e.getMessage()); throw e; } catch (NoSuchObjectException e) { - throw new NoSuchTableException("Table {} not found: {}", baseTableIdentifier, e.getMessage()); + throw new NoSuchTableException("Table %s not found: %s", baseTableIdentifier, e.getMessage()); } catch (TException e) { LOGGER.info("Table {} parameters fetch failed: {}", baseTableIdentifier, e.getMessage()); throw new RuntimeException("Failed to fetch table parameters for " + baseTableIdentifier, e); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 74dfb4d97aa6..8e1973894b26 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -46,7 +46,6 @@ import org.apache.iceberg.catalog.ViewCatalog; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; -import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.hive.HiveCatalog; import org.apache.iceberg.hive.MetadataLocator; import org.apache.iceberg.view.View; @@ -91,9 +90,9 @@ public HiveCatalog getCatalog() { // which can significantly reduce the latency for repeated access to the same table. private final Map l1Cache; // The TTL for L1 cache (3s). - private final int L1TTL_MS; + private final int l1Ttl; // The L1 cache size. - private final int L1_CACHE_SIZE; + private final int l1CacheSize; // Metrics counters. private final AtomicLong cacheHitCount = new AtomicLong(0); @@ -122,15 +121,15 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSen l1Cache = Collections.synchronizedMap(new LinkedHashMap() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > L1_CACHE_SIZE; + return size() > l1CacheSize; } }); - L1TTL_MS = l1ttl; - L1_CACHE_SIZE = l1size; + l1Ttl = l1ttl; + l1CacheSize = l1size; } else { l1Cache = Collections.emptyMap(); - L1TTL_MS = 0; - L1_CACHE_SIZE = 0; + l1Ttl = 0; + l1CacheSize = 0; } } @@ -266,7 +265,7 @@ public Table loadTable(final TableIdentifier identifier) { // which can significantly reduce the latency for repeated access to the same table. Long lastCached = l1Cache.get(canonicalized); if (lastCached != null) { - if (now - lastCached < L1TTL_MS) { + if (now - lastCached < l1Ttl) { LOG.debug("Table {} is in L1 cache, returning cached table", canonicalized); onCacheHit(canonicalized); return cachedTable; @@ -321,6 +320,7 @@ public Table loadTable(final TableIdentifier identifier) { } } } + l1Cache.put(canonicalized, now); onCacheLoad(canonicalized); return table; } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java index 9e3fa6c164b7..f8614d0771f7 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java @@ -26,7 +26,9 @@ public record HMSCacheStatsResponse(Map stats) implements RESTResponse { public HMSCacheStatsResponse(Map stats) { - this.stats = Collections.unmodifiableMap(new TreeMap<>(stats)); + this.stats = stats == null || stats.isEmpty() + ? Collections.emptyMap() + : Collections.unmodifiableMap(new TreeMap<>(stats)); } @Override diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index 2d9086fdf408..471b9e405fd3 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -34,7 +34,6 @@ import org.apache.iceberg.PartitionSpec; import org.apache.iceberg.Schema; import org.apache.iceberg.Table; -import org.apache.iceberg.catalog.Catalog; import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.hive.HiveCatalog; @@ -80,6 +79,7 @@ class TestHMSCachingCatalogStats { void setupAll() { catalog = RCKUtils.initCatalogClient(clientConfig()); serverCatalog = HMSCachingCatalog.getLatestCache(HMSCachingCatalog::getCatalog); + Assertions.assertNotNull(serverCatalog, "Expected HMSCachingCatalog to be initialized"); } /** Remove any namespace/table created by the test so each run starts clean. */ From 938e806f1169115d8706d2464998291595f655a9 Mon Sep 17 00:00:00 2001 From: Henrib Date: Tue, 12 May 2026 10:21:21 +0200 Subject: [PATCH 07/20] HIVE-29035: add JMX MXBean interface for HMSCachingCatalog to expose cache performance metrics; - remove end point to access cache performance metrics; - enhanced tests to check L1 cache; - simplified MetadataLocator exception handling; --- .../apache/iceberg/hive/MetadataLocator.java | 38 +-- .../iceberg/rest/HMSCachingCatalog.java | 165 +++++++++-- .../iceberg/rest/HMSCachingCatalogMXBean.java | 104 +++++++ .../iceberg/rest/HMSCatalogAdapter.java | 9 - .../rest/responses/HMSCacheStatsResponse.java | 38 --- .../rest/TestHMSCachingCatalogStats.java | 260 ++++++++++++------ 6 files changed, 442 insertions(+), 172 deletions(-) create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java delete mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index 1a90111452f9..c09212feb363 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -18,6 +18,9 @@ package org.apache.iceberg.hive; +import java.util.Collections; +import java.util.List; + import org.apache.hadoop.hive.metastore.IMetaStoreClient; import org.apache.hadoop.hive.metastore.api.GetProjectionsSpec; import org.apache.hadoop.hive.metastore.api.NoSuchObjectException; @@ -30,9 +33,6 @@ import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.thrift.TException; -import java.util.Collections; -import java.util.List; - /** * Fetches the location of a given metadata table. *

Since the location mutates with each transaction, this allows determining if a cached version of the @@ -40,9 +40,10 @@ */ public class MetadataLocator { private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(MetadataLocator.class); - private static final GetProjectionsSpec PARAM_SPEC = new GetTableProjectionsSpecBuilder() - .includeParameters() // only fetches table.parameters - .build(); + private static final GetProjectionsSpec PARAM_SPEC = + new GetTableProjectionsSpecBuilder() + .includeParameters() // only fetches table.parameters + .build(); private final HiveCatalog catalog; public MetadataLocator(HiveCatalog catalog) { @@ -77,19 +78,22 @@ public String getLocation(TableIdentifier identifier) { String database = baseTableIdentifier.namespace().level(0); String tableName = baseTableIdentifier.name(); try { - List

tables = clients.run( - client -> client.getTables(catName, database, Collections.singletonList(tableName), PARAM_SPEC) - ); - return tables == null || tables.isEmpty() - ? null - : tables.getFirst().getParameters().get(BaseMetastoreTableOperations.METADATA_LOCATION_PROP); - } catch (NoSuchTableException e) { - LOGGER.debug("Table {} not found: {}", baseTableIdentifier, e.getMessage()); - throw e; + List
tables = + clients.run(client -> client.getTables(catName, database, Collections.singletonList(tableName), PARAM_SPEC)); + if (tables != null && !tables.isEmpty()) { + Table table = tables.getFirst(); + if (table != null) { + HiveOperationsBase.validateTableIsIceberg(table, tableName); + return table.getParameters().get(BaseMetastoreTableOperations.METADATA_LOCATION_PROP); + } + } + return null; } catch (NoSuchObjectException e) { - throw new NoSuchTableException("Table %s not found: %s", baseTableIdentifier, e.getMessage()); + // NoSuchObjectException is a TException subclass that HMS may raise for an unknown database or catalog. + LOGGER.debug("Table {} not found: {}", baseTableIdentifier, e.getMessage()); + throw new NoSuchTableException(e, "Table %s not found: %s", baseTableIdentifier, e.getMessage()); } catch (TException e) { - LOGGER.info("Table {} parameters fetch failed: {}", baseTableIdentifier, e.getMessage()); + LOGGER.warn("Table {} parameters fetch failed: {}", baseTableIdentifier, e.getMessage()); throw new RuntimeException("Failed to fetch table parameters for " + baseTableIdentifier, e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 8e1973894b26..d3ae82cb5725 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -18,8 +18,8 @@ package org.apache.iceberg.rest; -import com.github.benmanes.caffeine.cache.Ticker; - +import java.io.Closeable; +import java.lang.management.ManagementFactory; import java.lang.ref.SoftReference; import java.util.Collections; import java.util.LinkedHashMap; @@ -29,6 +29,10 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; +import javax.management.JMException; +import javax.management.MBeanServer; +import javax.management.ObjectName; + import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.conf.HiveConf; import org.apache.iceberg.BaseMetadataTable; @@ -54,17 +58,21 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.github.benmanes.caffeine.cache.Ticker; + /** * Class that wraps an Iceberg Catalog to cache tables. */ -public class HMSCachingCatalog extends CachingCatalog implements SupportsNamespaces, ViewCatalog { +public class HMSCachingCatalog extends CachingCatalog + implements SupportsNamespaces, ViewCatalog, HMSCachingCatalogMXBean, Closeable { protected static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); @TestOnly private static SoftReference cacheRef = new SoftReference<>(null); - @TestOnly @SuppressWarnings("unchecked") - public static C getLatestCache(Function extractor) { + @TestOnly + @SuppressWarnings("unchecked") + public static C getLatestCache(Function extractor) { HMSCachingCatalog cache = cacheRef.get(); if (cache == null) { return null; @@ -100,6 +108,12 @@ public HiveCatalog getCatalog() { private final AtomicLong cacheLoadCount = new AtomicLong(0); private final AtomicLong cacheInvalidateCount = new AtomicLong(0); private final AtomicLong cacheMetaLoadCount = new AtomicLong(0); + // L1 cache metrics: counted only when the L2 (Caffeine) cache already has the entry. + private final AtomicLong l1CacheHitCount = new AtomicLong(0); + private final AtomicLong l1CacheMissCount = new AtomicLong(0); + + // JMX ObjectName under which this instance is registered (may be null if registration failed). + private ObjectName jmxObjectName; public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { this(catalog, expirationMs, /*caseSensitive*/ true); @@ -118,18 +132,42 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSen int l1size = conf.getInt("hms.caching.catalog.l1.cache.size", 32); int l1ttl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); if (l1size > 0 && l1ttl > 0) { - l1Cache = Collections.synchronizedMap(new LinkedHashMap() { + l1Cache = Collections.synchronizedMap(new LinkedHashMap() { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > l1CacheSize; } }); - l1Ttl = l1ttl; - l1CacheSize = l1size; + l1Ttl = l1ttl; + l1CacheSize = l1size; } else { - l1Cache = Collections.emptyMap(); - l1Ttl = 0; - l1CacheSize = 0; + l1Cache = Collections.emptyMap(); + l1Ttl = 0; + l1CacheSize = 0; + } + registerJmx(catalog.name()); + } + + /** + * Registers this instance as a JMX MBean. + * + * @param catalogName the catalog name, used to build the {@link ObjectName} + */ + private void registerJmx(String catalogName) { + try { + MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); + String sanitized = catalogName == null || catalogName.isEmpty() + ? "default" + : catalogName.replaceAll("[^a-zA-Z0-9.\\\\-]", "_"); + ObjectName name = new ObjectName("org.apache.iceberg.rest:type=HMSCachingCatalog,name=" + sanitized); + if (mbs.isRegistered(name)) { + mbs.unregisterMBean(name); + } + mbs.registerMBean(this, name); + this.jmxObjectName = name; + LOG.info("Registered JMX MBean: {}", name); + } catch (JMException e) { + LOG.warn("Failed to register JMX MBean for HMSCachingCatalog", e); } } @@ -183,51 +221,114 @@ protected void onCacheMetaLoad(TableIdentifier tid) { LOG.debug("Cache meta-load {}: {}", tid, count); } + /** + * Callback when an L1 cache hit occurs for a given table identifier. + * Only fired when the L2 cache also has the entry. + * + * @param tid the table identifier + */ + protected void onL1CacheHit(TableIdentifier tid) { + long count = l1CacheHitCount.incrementAndGet(); + LOG.debug("L1 cache hit {}: {}", tid, count); + } + + /** + * Callback when an L1 cache miss occurs for a given table identifier. + * Only fired when the L2 cache has the entry but L1 is absent or expired. + * + * @param tid the table identifier + */ + protected void onL1CacheMiss(TableIdentifier tid) { + long count = l1CacheMissCount.incrementAndGet(); + LOG.debug("L1 cache miss {}: {}", tid, count); + } + // Getter methods for accessing metrics + @Override public long getCacheHitCount() { return cacheHitCount.get(); } + @Override public long getCacheMissCount() { return cacheMissCount.get(); } + @Override public long getCacheLoadCount() { return cacheLoadCount.get(); } + @Override public long getCacheInvalidateCount() { return cacheInvalidateCount.get(); } + @Override public long getCacheMetaLoadCount() { return cacheMetaLoadCount.get(); } + @Override public double getCacheHitRate() { long hits = cacheHitCount.get(); long total = hits + cacheMissCount.get(); return total == 0 ? 0.0 : (double) hits / total; } + @Override + public long getL1CacheHitCount() { + return l1CacheHitCount.get(); + } + + @Override + public long getL1CacheMissCount() { + return l1CacheMissCount.get(); + } + + @Override + public double getL1CacheHitRate() { + long hits = l1CacheHitCount.get(); + long total = hits + l1CacheMissCount.get(); + return total == 0 ? 0.0 : (double) hits / total; + } + + @Override + public void resetCacheStats() { + cacheHitCount.set(0); + cacheMissCount.set(0); + cacheLoadCount.set(0); + cacheInvalidateCount.set(0); + cacheMetaLoadCount.set(0); + l1CacheHitCount.set(0); + l1CacheMissCount.set(0); + LOG.debug("Cache stats reset"); + } + + @Override + public void close() { + unregisterJmx(); + } + /** - * Generates a map of this cache's performance metrics, including hit count, - * miss count, load count, invalidate count, meta-load count, and hit rate. - * This can be used for monitoring and debugging purposes to understand the effectiveness of the cache. - * @return a map of cache performance metrics + * Unregisters this instance from the platform MBeanServer. */ - public Map cacheStats() { - return Map.of( - "hit", getCacheHitCount(), - "miss", getCacheMissCount(), - "load", getCacheLoadCount(), - "invalidate", getCacheInvalidateCount(), - "metaload", getCacheMetaLoadCount(), - "hit-rate", getCacheHitRate() - ); + private void unregisterJmx() { + if (jmxObjectName != null) { + try { + MBeanServer mbs = ManagementFactory.getPlatformMBeanServer(); + if (mbs.isRegistered(jmxObjectName)) { + mbs.unregisterMBean(jmxObjectName); + LOG.info("Unregistered JMX MBean: {}", jmxObjectName); + } + } catch (JMException e) { + LOG.warn("Failed to unregister JMX MBean: {}", jmxObjectName, e); + } finally { + jmxObjectName = null; + } + } } - @Override public void createNamespace(Namespace namespace, Map map) { hiveCatalog.createNamespace(namespace, map); @@ -267,11 +368,15 @@ public Table loadTable(final TableIdentifier identifier) { if (lastCached != null) { if (now - lastCached < l1Ttl) { LOG.debug("Table {} is in L1 cache, returning cached table", canonicalized); + onL1CacheHit(canonicalized); onCacheHit(canonicalized); return cachedTable; } else { l1Cache.remove(canonicalized); + onL1CacheMiss(canonicalized); } + } else { + onL1CacheMiss(canonicalized); } // If the table is no longer in L1 cache, we need to check the location. final String location = metadataLocator.getLocation(canonicalized); @@ -281,9 +386,8 @@ public Table loadTable(final TableIdentifier identifier) { l1Cache.put(canonicalized, now); return cachedTable; } - String cachedLocation = cachedTable instanceof HasTableOperations tableOps - ? tableOps.operations().current().metadataFileLocation() - : null; + String cachedLocation = + cachedTable instanceof HasTableOperations tableOps ? tableOps.operations().current().metadataFileLocation() : null; if (location.equals(cachedLocation)) { onCacheHit(canonicalized); l1Cache.put(canonicalized, now); @@ -310,7 +414,8 @@ public Table loadTable(final TableIdentifier identifier) { MetadataTableType type = MetadataTableType.from(canonicalized.name()); // Defensive: CachingCatalog doesn't perform this check if (type != null) { - Table metadataTable = MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); + Table metadataTable = + MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); tableCache.put(canonicalized, metadataTable); l1Cache.put(canonicalized, now); onCacheMetaLoad(canonicalized); @@ -326,7 +431,7 @@ public Table loadTable(final TableIdentifier identifier) { } private Table loadTableWithoutCache(TableIdentifier identifier) { - return hiveCatalog.loadTable(identifier); + return hiveCatalog.loadTable(identifier); } @Override diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java new file mode 100644 index 000000000000..d72c8e103ade --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.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.iceberg.rest; + +/** + * JMX MXBean interface for {@link HMSCachingCatalog} that exposes cache performance statistics. + *

+ * Instances are registered under the object name: + * {@code org.apache.iceberg.rest:type=HMSCachingCatalog,name=<catalogName>}. + *

+ */ +public interface HMSCachingCatalogMXBean { + + /** + * Returns the total number of cache hits (table found in cache and still valid). + * + * @return cache hit count + */ + long getCacheHitCount(); + + /** + * Returns the total number of cache misses (table not found in cache). + * + * @return cache miss count + */ + long getCacheMissCount(); + + /** + * Returns the total number of times a table was loaded from the underlying catalog and stored in cache. + * + * @return cache load count + */ + long getCacheLoadCount(); + + /** + * Returns the total number of times a cached table was invalidated because the actual metadata location differed. + * + * @return cache invalidation count + */ + long getCacheInvalidateCount(); + + /** + * Returns the total number of times a metadata (virtual) table was loaded and cached. + * + * @return cache metadata-table load count + */ + long getCacheMetaLoadCount(); + + /** + * Returns the cache hit rate as a value in the range {@code [0.0, 1.0]}. + * Returns {@code 0.0} when no lookups have been performed. + * + * @return cache hit rate + */ + double getCacheHitRate(); + + /** + * Returns the total number of L1 (short-lived in-memory) cache hits. + * An L1 hit means the table was found in the L2 cache and its L1 TTL had not yet expired, + * so the HMS metadata-location check was skipped entirely. + * + * @return L1 cache hit count + */ + long getL1CacheHitCount(); + + /** + * Returns the total number of L1 cache misses. + * An L1 miss means the table was in the L2 cache but the L1 entry was absent or expired, + * so an HMS metadata-location check was required. + * + * @return L1 cache miss count + */ + long getL1CacheMissCount(); + + /** + * Returns the L1 cache hit rate as a value in the range {@code [0.0, 1.0]}. + * This reflects how often the short-circuit L1 path is taken vs. the full HMS location check. + * Returns {@code 0.0} when no L2-cache-hit lookups have been performed. + * + * @return L1 cache hit rate + */ + double getL1CacheHitRate(); + + /** + * Resets all cache statistics counters to zero. + */ + void resetCacheStats(); +} diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java index e3dae744ba71..ca87a1951dc0 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java @@ -75,7 +75,6 @@ import org.apache.iceberg.rest.responses.ListTablesResponse; import org.apache.iceberg.rest.responses.LoadTableResponse; import org.apache.iceberg.rest.responses.LoadViewResponse; -import org.apache.iceberg.rest.responses.HMSCacheStatsResponse; import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse; import org.apache.iceberg.util.Pair; import org.apache.iceberg.util.PropertyUtil; @@ -229,14 +228,6 @@ public Class requestClass() { } } - private HMSCacheStatsResponse cacheStats() { - Map stats = Collections.emptyMap(); - if (catalog instanceof HMSCachingCatalog hmsCatalog) { - stats = hmsCatalog.cacheStats(); - } - return castResponse(HMSCacheStatsResponse.class, new HMSCacheStatsResponse(stats)); - } - private ConfigResponse config() { final List endpoints = Arrays.stream(Route.values()) .map(r -> Endpoint.create(r.method.name(), r.resourcePath)).toList(); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.java deleted file mode 100644 index f8614d0771f7..000000000000 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/responses/HMSCacheStatsResponse.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.iceberg.rest.responses; - -import org.apache.iceberg.rest.RESTResponse; - -import java.util.Collections; -import java.util.Map; -import java.util.TreeMap; - -public record HMSCacheStatsResponse(Map stats) implements RESTResponse { - public HMSCacheStatsResponse(Map stats) { - this.stats = stats == null || stats.isEmpty() - ? Collections.emptyMap() - : Collections.unmodifiableMap(new TreeMap<>(stats)); - } - - @Override - public void validate() { - // nothing - } -} diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index 471b9e405fd3..33317f0b30ab 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -19,12 +19,11 @@ package org.apache.iceberg.rest; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Map; +import java.lang.management.ManagementFactory; +import java.util.Set; + +import javax.management.MBeanServer; +import javax.management.ObjectName; import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; import org.apache.hadoop.hive.metastore.annotation.MetastoreCheckinTest; @@ -38,7 +37,6 @@ import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.hive.HiveCatalog; import org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension; -import org.apache.iceberg.rest.responses.HMSCacheStatsResponse; import org.junit.experimental.categories.Category; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; @@ -49,8 +47,9 @@ /** * Integration tests that verify the {@link HMSCachingCatalog} cache-statistics counters - * (hit, miss, load, hit-rate) are updated correctly and exposed accurately via the - * {@code GET v1/cache/stats} REST endpoint. + * (hit, miss, load, invalidate, l1-hit, l1-miss, and their rates) are updated correctly + * and exposed accurately via the JMX MBean registered under + * {@code org.apache.iceberg.rest:type=HMSCachingCatalog,name=*}. * *

The server is started with {@link AuthType#NONE} so the tests focus purely on * caching behaviour without any authentication noise. @@ -63,23 +62,35 @@ class TestHMSCachingCatalogStats { private static final long CACHE_EXPIRY_MS = 5 * 60 * 1_000L; @RegisterExtension - private static final HiveRESTCatalogServerExtension REST_CATALOG_EXTENSION = - HiveRESTCatalogServerExtension.builder(AuthType.NONE) - // Without a positive expiry the HMSCatalogFactory skips HMSCachingCatalog entirely. - .configure( - MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), - String.valueOf(CACHE_EXPIRY_MS)) - .configure("hive.in.test", "true") - .build(); + private static final HiveRESTCatalogServerExtension REST_CATALOG_EXTENSION = HiveRESTCatalogServerExtension.builder(AuthType.NONE) + // Without a positive expiry the HMSCatalogFactory skips HMSCachingCatalog entirely. + .configure(MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), String.valueOf(CACHE_EXPIRY_MS)) + .configure("hive.in.test", "true").build(); private RESTCatalog catalog; private HiveCatalog serverCatalog; + /** The server-side {@link HMSCachingCatalog} instance; used to invalidate entries directly. */ + private HMSCachingCatalog serverCachingCatalog; + /** The platform {@link MBeanServer} used for all JMX-based assertions. */ + private MBeanServer mbs; + /** Resolved once in {@link #setupAll()} and reused across every test. */ + private ObjectName jmxObjectName; @BeforeAll - void setupAll() { - catalog = RCKUtils.initCatalogClient(clientConfig()); - serverCatalog = HMSCachingCatalog.getLatestCache(HMSCachingCatalog::getCatalog); - Assertions.assertNotNull(serverCatalog, "Expected HMSCachingCatalog to be initialized"); + void setupAll() throws Exception { + catalog = RCKUtils.initCatalogClient(java.util.Map.of("uri", REST_CATALOG_EXTENSION.getRestEndpoint())); + serverCachingCatalog = HMSCachingCatalog.getLatestCache(null); + Assertions.assertNotNull(serverCachingCatalog, "Expected HMSCachingCatalog to be initialized"); + serverCatalog = serverCachingCatalog.getCatalog(); + + // Resolve the JMX ObjectName registered by HMSCachingCatalog. We use a wildcard + // so the test is independent of the exact catalog name. + mbs = ManagementFactory.getPlatformMBeanServer(); + Set names = mbs.queryNames( + new ObjectName("org.apache.iceberg.rest:type=HMSCachingCatalog,*"), null); + Assertions.assertFalse(names.isEmpty(), + "HMSCachingCatalog MBean must be registered in the platform MBeanServer"); + jmxObjectName = names.iterator().next(); } /** Remove any namespace/table created by the test so each run starts clean. */ @@ -92,56 +103,70 @@ void cleanup() { // helpers // --------------------------------------------------------------------------- - private java.util.Map clientConfig() { - return java.util.Map.of("uri", REST_CATALOG_EXTENSION.getRestEndpoint()); + /** + * Reads a single JMX attribute from the {@link HMSCachingCatalogMXBean}. + * + * @param attribute the attribute name as declared in {@link HMSCachingCatalogMXBean} + * (e.g. {@code "CacheHitCount"}) + * @return the attribute value + */ + private Object getJmxAttribute(String attribute) throws Exception { + return mbs.getAttribute(jmxObjectName, attribute); + } + + /** + * Convenience wrapper that reads a {@code long} JMX attribute. + */ + private long jmxLong(String attribute) throws Exception { + return (long) getJmxAttribute(attribute); + } + + /** + * Convenience wrapper that reads a {@code double} JMX attribute. + */ + private double jmxDouble(String attribute) throws Exception { + return (double) getJmxAttribute(attribute); } /** - * Calls the {@code GET v1/cache/stats} endpoint directly over HTTP and returns - * the deserialised {@link HMSCacheStatsResponse}. + * Invokes a void JMX operation on the {@link HMSCachingCatalogMXBean}. + * + * @param operationName the operation name (e.g. {@code "resetCacheStats"}) */ - private HMSCacheStatsResponse fetchCacheStats() throws Exception { - String statsUrl = REST_CATALOG_EXTENSION.getRestEndpoint() + "/v1/cache/stats"; - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(statsUrl)) - .GET() - .build(); - HttpResponse response; - try (HttpClient client = HttpClient.newHttpClient()) { - response = client.send(request, HttpResponse.BodyHandlers.ofString()); - } - Assertions.assertEquals(200, response.statusCode(), - "Expected HTTP 200 from cache stats endpoint, got: " + response.statusCode()); - return new ObjectMapper().readValue(response.body(), HMSCacheStatsResponse.class); + private void invokeJmxOperation(String operationName) throws Exception { + mbs.invoke(jmxObjectName, operationName, new Object[0], new String[0]); } + // --------------------------------------------------------------------------- + // tests + // --------------------------------------------------------------------------- /** - * Verifies that the {@link HMSCachingCatalog} correctly tracks cache hits, misses and - * loads, and that those counters are accurately returned via the REST endpoint. + * Verifies that the {@link HMSCachingCatalog} correctly tracks cache hits, misses, + * loads, invalidations, L1 hits, and L1 misses via JMX. * *

Strategy: *

    - *
  1. Snapshot baseline stats before any operations so the test is isolated from - * cumulative counters left by previous tests.
  2. - *
  3. Create a namespace and a table (bypasses the cache – done via - * {@link org.apache.iceberg.hive.HiveCatalog} directly).
  4. + *
  5. Snapshot JMX baseline counters before any operations so the test is isolated + * from cumulative state left by previous tests.
  6. + *
  7. Create a namespace and a table.
  8. *
  9. First {@code loadTable} call → cache miss + actual load.
  10. - *
  11. Second and third {@code loadTable} calls → cache hits (metadata location - * has not changed, so the cached entry is still valid).
  12. - *
  13. Fetch stats again and assert the deltas against the baseline.
  14. + *
  15. Second and third rapid {@code loadTable} calls → L1 cache hits (TTL still valid).
  16. + *
  17. Mutate the table to advance its metadata location in HMS.
  18. + *
  19. Wait for the L1 TTL to expire, then reload → L1 miss + invalidation + reload.
  20. + *
  21. Assert JMX counter deltas match expectations.
  22. *
*/ @Test void testCacheCountersAreUpdated() throws Exception { - // -- baseline --------------------------------------------------------------- - Map baseline = fetchCacheStats().stats(); - long baseHit = baseline.getOrDefault("hit", 0L).longValue(); - long baseMiss = baseline.getOrDefault("miss", 0L).longValue(); - long baseLoad = baseline.getOrDefault("load", 0L).longValue(); + // -- JMX baseline ----------------------------------------------------------- + long baseHit = jmxLong("CacheHitCount"); + long baseMiss = jmxLong("CacheMissCount"); + long baseLoad = jmxLong("CacheLoadCount"); + long baseL1Hit = jmxLong("L1CacheHitCount"); // -- exercise the cache ----------------------------------------------------- - var db = Namespace.of("caching_stats_test_db"); + var db = Namespace.of("caching_stats_test_db"); var tableId = TableIdentifier.of(db, "caching_stats_test_table"); catalog.createNamespace(db); @@ -149,9 +174,9 @@ void testCacheCountersAreUpdated() throws Exception { // First load → cache miss + load catalog.loadTable(tableId); - // Second load → cache hit (metadata location unchanged) + // Second load → L1 hit (within TTL, HMS location check skipped) catalog.loadTable(tableId); - // Third load → cache hit + // Third load → L1 hit catalog.loadTable(tableId); // Mutate the table by appending a data file – this creates a new snapshot @@ -160,40 +185,119 @@ void testCacheCountersAreUpdated() throws Exception { Table table = serverCatalog.loadTable(tableId); DataFile dataFile = DataFiles.builder(PartitionSpec.unpartitioned()) .withPath(table.location() + "/data/fake-0.parquet") - .withFileSizeInBytes(1024) - .withRecordCount(1) - .build(); - table.newAppend() - .appendFile(dataFile) - .commit(); - - long baseInvalidate = fetchCacheStats().stats().getOrDefault("invalidate", 0L).longValue(); - // the L1 cache has a 3 seconds default delay before it considers entries stale + .withFileSizeInBytes(1024).withRecordCount(1).build(); + table.newAppend().appendFile(dataFile).commit(); + + long baseInvalidate = jmxLong("CacheInvalidateCount"); + // The L1 cache has a 3-second default TTL; wait for entries to expire. Thread.sleep(3_000); - // Fourth load → cache invalidation + load (cached location != HMS location) + // Fourth load → L1 miss + cache invalidation + reload catalog.loadTable(tableId); - // -- fetch updated stats via the REST endpoint ------------------------------ - Map after = fetchCacheStats().stats(); - long deltaHit = after.getOrDefault("hit", 0L).longValue() - baseHit; - long deltaMiss = after.getOrDefault("miss", 0L).longValue() - baseMiss; - long deltaLoad = after.getOrDefault("load", 0L).longValue() - baseLoad; - long deltaInvalidate = after.getOrDefault("invalidate", 0L).longValue() - baseInvalidate; + // -- JMX assertions --------------------------------------------------------- + long deltaHit = jmxLong("CacheHitCount") - baseHit; + long deltaMiss = jmxLong("CacheMissCount") - baseMiss; + long deltaLoad = jmxLong("CacheLoadCount") - baseLoad; + long deltaInvalidate = jmxLong("CacheInvalidateCount") - baseInvalidate; + long deltaL1Hit = jmxLong("L1CacheHitCount") - baseL1Hit; + long deltaL1Miss = jmxLong("L1CacheMissCount"); // absolute value is fine for L1 miss - // -- assertions ------------------------------------------------------------- Assertions.assertTrue(deltaMiss >= 1, "Expected at least 1 cache miss (first loadTable), but delta was: " + deltaMiss); Assertions.assertTrue(deltaLoad >= 2, - "Expected at least 2 cache loads (initial load + post-invalidation reload), but delta was: " + deltaLoad); + "Expected at least 2 cache loads (initial + post-invalidation reload), but delta was: " + deltaLoad); Assertions.assertTrue(deltaHit >= 2, "Expected at least 2 cache hits (second + third loadTable), but delta was: " + deltaHit); Assertions.assertTrue(deltaInvalidate >= 1, - "Expected at least 1 cache invalidation (metadata location changed after table update), but delta was: " + deltaInvalidate); + "Expected at least 1 cache invalidation (metadata location changed), but delta was: " + deltaInvalidate); - // hit-rate must be a valid ratio in [0.0, 1.0] - double hitRate = after.getOrDefault("hit-rate", 0.0).doubleValue(); + // L1 hits: the 2nd and 3rd loadTable calls should have been served by L1. + Assertions.assertTrue(deltaL1Hit >= 2, + "Expected at least 2 L1 cache hits (rapid successive loads within TTL), but delta was: " + deltaL1Hit); + // L1 miss: at least the fourth load (after TTL expiry) must have missed L1. + Assertions.assertTrue(deltaL1Miss >= 1, + "Expected at least 1 L1 cache miss (after TTL expiry), but was: " + deltaL1Miss); + + // Rate attributes must be valid ratios in [0.0, 1.0]. + double hitRate = jmxDouble("CacheHitRate"); Assertions.assertTrue(hitRate > 0.0 && hitRate <= 1.0, - "hit-rate must be in [0.0, 1.0] but was: " + hitRate); + "CacheHitRate must be in (0.0, 1.0] but was: " + hitRate); + + double l1HitRate = jmxDouble("L1CacheHitRate"); + Assertions.assertTrue(l1HitRate > 0.0 && l1HitRate <= 1.0, + "L1CacheHitRate must be in (0.0, 1.0] but was: " + l1HitRate); } -} + /** + * Verifies that the {@code resetCacheStats} JMX operation zeroes all counters. + * + *

Strategy: + *

    + *
  1. Perform some cache operations to ensure all counters are non-zero.
  2. + *
  3. Invoke {@code resetCacheStats()} via JMX.
  4. + *
  5. Assert that every JMX counter attribute reads {@code 0} / {@code 0.0}.
  6. + *
+ */ + @Test + void testJmxResetCacheStats() throws Exception { + // -- warm up counters ------------------------------------------------------- + var db = Namespace.of("jmx_reset_test_db"); + var tableId = TableIdentifier.of(db, "jmx_reset_test_table"); + catalog.createNamespace(db); + catalog.createTable(tableId, new Schema()); + catalog.loadTable(tableId); // miss + load + catalog.loadTable(tableId); // hit (L1 hit on the fast path) + + // Sanity: at least one counter must be non-zero before the reset. + Assertions.assertTrue(jmxLong("CacheHitCount") + jmxLong("CacheMissCount") > 0, + "At least one counter must be non-zero before reset"); + + // -- invoke the reset operation via JMX ------------------------------------- + invokeJmxOperation("resetCacheStats"); + + // -- assertions post-reset -------------------------------------------------- + Assertions.assertEquals(0L, jmxLong("CacheHitCount"), "CacheHitCount must be 0 after reset"); + Assertions.assertEquals(0L, jmxLong("CacheMissCount"), "CacheMissCount must be 0 after reset"); + Assertions.assertEquals(0L, jmxLong("CacheLoadCount"), "CacheLoadCount must be 0 after reset"); + Assertions.assertEquals(0L, jmxLong("CacheInvalidateCount"), "CacheInvalidateCount must be 0 after reset"); + Assertions.assertEquals(0L, jmxLong("CacheMetaLoadCount"), "CacheMetaLoadCount must be 0 after reset"); + Assertions.assertEquals(0L, jmxLong("L1CacheHitCount"), "L1CacheHitCount must be 0 after reset"); + Assertions.assertEquals(0L, jmxLong("L1CacheMissCount"), "L1CacheMissCount must be 0 after reset"); + Assertions.assertEquals(0.0, jmxDouble("CacheHitRate"), 1e-9, "CacheHitRate must be 0.0 after reset"); + Assertions.assertEquals(0.0, jmxDouble("L1CacheHitRate"), 1e-9, "L1CacheHitRate must be 0.0 after reset"); + + // -- verify rate calculation still works correctly after reset -------------- + // resetCacheStats() zeroes counters but does NOT evict the L2/L1 cache, so the + // table is still cached. Invalidate it directly on the server-side HMSCachingCatalog + // so the first post-reset load is a genuine cold miss rather than an L1/L2 hit. + // NOTE: catalog.invalidateTable() only clears the REST *client* state and does not + // reach the server-side cache. + serverCachingCatalog.invalidateTable(tableId); + + // First load after reset: cache miss + load (L1 cold, L2 cold). + catalog.loadTable(tableId); + // Second load: L1 hit (within TTL). + catalog.loadTable(tableId); + // Third load: L1 hit (within TTL). + catalog.loadTable(tableId); + + // CacheHitRate: 2 hits out of 3 total accesses → ≈ 0.667 + double hitRateAfterReset = jmxDouble("CacheHitRate"); + Assertions.assertTrue(hitRateAfterReset > 0.0 && hitRateAfterReset <= 1.0, + "CacheHitRate must be in (0.0, 1.0] after post-reset operations, but was: " + hitRateAfterReset); + + // Underlying counters must reflect the just-performed operations. + Assertions.assertTrue(jmxLong("CacheHitCount") >= 2, + "CacheHitCount must be >= 2 after two rapid re-loads post-reset"); + Assertions.assertTrue(jmxLong("CacheMissCount") >= 1, + "CacheMissCount must be >= 1 after the first cold load post-reset"); + + // L1CacheHitRate: the 2nd and 3rd loads should have been served by L1. + double l1HitRateAfterReset = jmxDouble("L1CacheHitRate"); + Assertions.assertTrue(l1HitRateAfterReset > 0.0 && l1HitRateAfterReset <= 1.0, + "L1CacheHitRate must be in (0.0, 1.0] after post-reset L1 hits, but was: " + l1HitRateAfterReset); + + Assertions.assertTrue(jmxLong("L1CacheHitCount") >= 2, + "L1CacheHitCount must be >= 2 after two rapid re-loads within TTL post-reset"); + } +} From ff822f3bb21eab115a2568baf5d0e137a5be31cc Mon Sep 17 00:00:00 2001 From: Henrib Date: Sat, 15 Aug 2026 17:02:05 +0200 Subject: [PATCH 08/20] HIVE-29035: enforce authorization in the caching Iceberg REST catalog The HMSCachingCatalog serves tables and access decisions out of an in-JVM cache to avoid HMS round-trips. Caching the catalog this way silently bypassed Ranger: once a Table object lived in the Caffeine cache, every subsequent loadTable/dropTable/rename served it without re-consulting the authorizer, so a user could read or mutate a table they were never granted. Caching must never widen access. This change makes every table, view and namespace operation go through an explicit per-request authorization check, and caches the *decision* (not just the table) so enforcement stays cheap. Authorization - New HMSPrivilegeHelper interface: resolves an AccessLevel (NONE / READ_ONLY / READ_WRITE) for a (db, table, user) or (db, user) triple, with isAvailable() to report whether an authorizer is wired. - New RangerPrivilegeHelper implementation calls the Hive authorizer's showPrivileges API directly (no Thrift hop) and maps Ranger's Hive access-type names onto AccessLevel: * read (shared): SELECT, READ * table/view write: UPDATE, WRITE, ALL (DML / data-plane) * namespace write: CREATE, ALTER, DROP, ALL (DDL) ALTER and DROP are DDL and are authorized at the namespace level, not per-table. Ranger qualifiers (e.g. "SELECT(ACCESS_CONDITIONAL)") are stripped before matching. - Fail-closed by default: when no authorizer is configured the helper returns NONE, so access is denied rather than open. Initialization failures likewise degrade to NONE. Only when authorization is explicitly disabled does the helper grant READ_WRITE. - HMSCachingCatalog enforces READ_ONLY for load/list and READ_WRITE for drop/rename/register/build on both tables and views, resolving the caller from UserGroupInformation.getCurrentUser(). Decision caching and invalidation - Access levels are held in a dedicated Caffeine cache keyed by TableIdentifier, expiring on the same TTL as the table cache. Namespace decisions use a synthetic TableIdentifier(namespace, "*") key that cannot collide with a real table. - Authorization entries are invalidated together with the object they guard: table-level on invalidateTable, namespace-level on dropNamespace. Catalog hardening - HMSCachingCatalog is now final; its cache callbacks and logger are private. It is instantiated only by HMSCatalogFactory. - tableExists uses MetadataLocator (a null location means no table), avoiding a full load. Tests - TestHMSCachingCatalogAuthz drives a StubPrivilegeHelper to assert the access matrix (grant/deny per level), that decisions are cached, and that cache invalidation re-checks authorization. - Surefire runs with reuseForks=false in this module to isolate JVM-static state (the metastore PMF and Iceberg's CachedClientPool) across classes. --- .../metastore-rest-catalog/pom.xml | 11 + .../hive/metastore/RangerPrivilegeHelper.java | 258 +++++++++++ .../iceberg/rest/HMSCachingCatalog.java | 327 +++++++++++--- .../iceberg/rest/HMSPrivilegeHelper.java | 62 +++ .../rest/TestHMSCachingCatalogAuthz.java | 402 ++++++++++++++++++ .../rest/TestHMSCachingCatalogStats.java | 90 ++-- 6 files changed, 1067 insertions(+), 83 deletions(-) create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java create mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.java create mode 100644 standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java diff --git a/standalone-metastore/metastore-rest-catalog/pom.xml b/standalone-metastore/metastore-rest-catalog/pom.xml index edeb7de1d730..f5f7ef7a79f9 100644 --- a/standalone-metastore/metastore-rest-catalog/pom.xml +++ b/standalone-metastore/metastore-rest-catalog/pom.xml @@ -314,6 +314,17 @@ org.apache.maven.plugins maven-surefire-plugin + + + false + org.apache.maven.plugins diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java new file mode 100644 index 000000000000..1fd7e78a9cfe --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.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.hadoop.hive.metastore; + +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.hadoop.hive.ql.metadata.HiveUtils; +import org.apache.hadoop.hive.ql.security.HiveAuthenticationProvider; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthorizerFactory; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthorizer; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthzSessionContext; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveMetastoreClientFactoryImpl; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeInfo; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject.HivePrivilegeObjectType; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrincipal; +import org.apache.iceberg.rest.HMSPrivilegeHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Set; + +/** + * Implements {@link HMSPrivilegeHelper} by calling Ranger's {@code showPrivileges} API directly, + * without routing through the Hive metastore Thrift endpoint. + * + *

Initialization

+ * {@link #create(Configuration)} reads {@code HIVE_AUTHORIZATION_MANAGER} from the supplied + * configuration to locate a {@link HiveAuthorizerFactory}. Three outcomes are possible: + *
    + *
  • Factory found – a fully wired {@code RangerPrivilegeHelper} is returned and + * {@link #isAvailable()} returns {@code true}.
  • + *
  • No factory configured – a pass-through helper that grants {@code READ_WRITE} + * on every object is returned and {@link #isAvailable()} returns {@code false}.
  • + *
  • Initialization exception – a {@code RangerPrivilegeHelper} with a {@code null} + * authorizer is returned; {@link #isAvailable()} returns {@code false} and every check + * returns {@link HMSPrivilegeHelper.AccessLevel#NONE}.
  • + *
+ * + *

Privilege mapping

+ * Both {@link #getAccessLevel} and {@link #getNamespaceAccessLevel} delegate to the shared + * {@link #queryPrivileges} method. The algorithm scans the privilege list returned by + * {@link HiveAuthorizer#showPrivileges}: + *
    + *
  1. If any privilege is in the object's write set, return {@code READ_WRITE} + * immediately (short-circuit).
  2. + *
  3. If any privilege is in the read set, set a read flag.
  4. + *
  5. After all privileges are scanned, return {@code READ_ONLY} if the read flag is set, + * otherwise {@code NONE}.
  6. + *
+ * Ranger sometimes appends qualifiers to privilege names (e.g. {@code "SELECT(ACCESS_CONDITIONAL)"}). + * These are stripped before the comparison. + * + *

Privilege sets (aligned with Ranger's Hive access-type model):

+ *
    + *
  • Read (shared) – {@code SELECT} (SQL standard), {@code READ} (Ranger data-plane alias)
  • + *
  • Table / view write (DML / data-plane) – {@code UPDATE} (Ranger's single + * data-mutation access type, covering insert/update/delete), {@code WRITE} (Ranger + * data-plane write alias), {@code ALL}. DDL privileges ({@code ALTER}, {@code DROP}) are + * authorized at the namespace level, not per-table.
  • + *
  • Namespace (database) write (DDL) – {@code CREATE}, {@code ALTER}, {@code DROP}, + * {@code ALL}. Data-plane grants ({@code UPDATE}, {@code WRITE}) are table-scoped and do + * not imply DDL access on the namespace.
  • + *
+ * + *

Any exception thrown by the Ranger API is caught and logged; the result is {@code NONE}.

+ */ +public class RangerPrivilegeHelper implements HMSPrivilegeHelper { + private static final Logger LOG = LoggerFactory.getLogger(RangerPrivilegeHelper.class); + + // Privileges that imply read access on any object type. + // SELECT is the SQL-standard read privilege; READ is Ranger's data-plane alias. + private static final Set READ_PRIVILEGES = Set.of("SELECT", "READ"); + // Privileges that grant READ_WRITE at the table / view level (DML / data-plane only). + // Ranger's Hive access-type model uses UPDATE to cover all data mutation (insert/update/delete); + // there are no separate INSERT or DELETE access types in Ranger. WRITE is Ranger's data-plane + // write alias (parallel to READ). DDL privileges (ALTER, DROP) are authorized at the namespace + // level, not per-table, so they are intentionally absent here. + private static final Set TABLE_WRITE_PRIVILEGES = + Set.of("UPDATE", "WRITE", "ALL"); + // Privileges that grant READ_WRITE at the namespace (database) level (DDL). + // CREATE/ALTER/DROP are the DDL operations authorized at the database level (including for the + // tables it contains). UPDATE/WRITE are table-scoped data-plane grants and do not belong here. + private static final Set NAMESPACE_WRITE_PRIVILEGES = + Set.of("CREATE", "ALTER", "DROP", "ALL"); + + // The Ranger authorizer instance, or null if initialization failed. + private final HiveAuthorizer authorizer; + + protected RangerPrivilegeHelper(HiveAuthorizer auth) { + this.authorizer = auth; + } + + /** + * Creates a new {@code RangerPrivilegeHelper} from the supplied configuration. + * + *

If the configuration does not specify a {@code HiveAuthorizerFactory}, a pass-through + * helper is returned that grants {@code READ_WRITE} on every object. If an exception occurs + * during initialization, a helper with a {@code null} authorizer is returned; every check + * will return {@link HMSPrivilegeHelper.AccessLevel#NONE}. + * + * @param conf the Hive configuration to read + * @return a new {@code RangerPrivilegeHelper} + */ + public static HMSPrivilegeHelper create(Configuration conf) { + HiveAuthorizer auth = null; + HiveConf hiveConf = (conf instanceof HiveConf) ? (HiveConf) conf : new HiveConf(conf, RangerPrivilegeHelper.class); + if (!hiveConf.getBoolVar(HiveConf.ConfVars.HIVE_AUTHORIZATION_ENABLED)) { + LOG.warn("RangerPrivilegeHelper: authorization is disabled ({}=false), all access granted.", + HiveConf.ConfVars.HIVE_AUTHORIZATION_ENABLED.varname); + return new HMSPrivilegeHelper() { + @Override + public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { + return AccessLevel.READ_WRITE; + } + @Override + public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { + return AccessLevel.READ_WRITE; + } + }; + } + try { + HiveAuthorizerFactory authorizerFactory = HiveUtils.getAuthorizerFactory(hiveConf, + HiveConf.ConfVars.HIVE_AUTHORIZATION_MANAGER); + if (authorizerFactory != null) { + LOG.debug("Using HiveAuthorizerFactory: {}", authorizerFactory.getClass().getName()); + + HiveAuthzSessionContext.Builder ctxBuilder = new HiveAuthzSessionContext.Builder(); + ctxBuilder.setClientType(HiveAuthzSessionContext.CLIENT_TYPE.OTHER); + ctxBuilder.setSessionString("IcebergRESTCatalog"); + HiveAuthzSessionContext sessionContext = ctxBuilder.build(); + + HiveAuthenticationProvider authenticator = HiveUtils.getAuthenticator( + hiveConf, HiveConf.ConfVars.HIVE_METASTORE_AUTHENTICATOR_MANAGER); + if (authenticator != null) { + authenticator.setConf(hiveConf); + } + + HiveMetastoreClientFactoryImpl clientFactory = new HiveMetastoreClientFactoryImpl(hiveConf); + auth = authorizerFactory.createHiveAuthorizer( + clientFactory, hiveConf, authenticator, sessionContext); + LOG.info("RangerPrivilegeHelper initialized with authorizer: {}", auth.getClass().getName()); + } else { + LOG.warn("RangerPrivilegeHelper: no authorizer factory found, all access granted. " + + "Check your Hive configuration for {}", + HiveConf.ConfVars.HIVE_AUTHORIZATION_MANAGER.varname); + return new HMSPrivilegeHelper() { + @Override + public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { + return AccessLevel.READ_WRITE; + } + @Override + public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { + return AccessLevel.READ_WRITE; + } + }; + } + } catch (Exception e) { + LOG.warn("RangerPrivilegeHelper: failed to initialize authorizer", e); + } + return new RangerPrivilegeHelper(auth); + } + + @Override + public boolean isAvailable() { + return authorizer != null; + } + + /** + * Returns the access level {@code userName} has on the table or view {@code dbName.tableName}. + */ + @Override + public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { + return queryPrivileges( + userName, + new HivePrivilegeObject(HivePrivilegeObjectType.TABLE_OR_VIEW, null, dbName, tableName), + TABLE_WRITE_PRIVILEGES); + } + + /** + * Returns the access level {@code userName} has on the namespace (database) {@code dbName}. + */ + @Override + public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { + return queryPrivileges( + userName, + new HivePrivilegeObject(HivePrivilegeObjectType.DATABASE, null, dbName, (String) null), + NAMESPACE_WRITE_PRIVILEGES); + } + + /** + * Core privilege evaluation: calls {@link HiveAuthorizer#showPrivileges} and maps the + * resulting list to an {@link AccessLevel} using the supplied {@code writePrivileges} set. + * + *

Returns {@link AccessLevel#NONE} immediately if the authorizer is {@code null}. + * Any exception from the Ranger API is caught, logged, and treated as {@code NONE}. + * + * @param userName the short user name to evaluate + * @param privObj the object to check (table, view, or database) + * @param writePrivileges upper-cased privilege names (object-type-specific) that grant + * {@code READ_WRITE}; read-only access is determined by + * {@link #READ_PRIVILEGES} + * @return the resolved access level + */ + private AccessLevel queryPrivileges(String userName, HivePrivilegeObject privObj, + Set writePrivileges) { + if (authorizer == null) { + LOG.debug("No authorizer available, defaulting to NONE for {} user={}", privObj, userName); + return AccessLevel.NONE; + } + try { + HivePrincipal principal = new HivePrincipal(userName, HivePrincipal.HivePrincipalType.USER); + List privileges = authorizer.showPrivileges(principal, privObj); + if (privileges == null || privileges.isEmpty()) { + LOG.debug("No privileges found for user {} on {}", userName, privObj); + return AccessLevel.NONE; + } + boolean hasRead = false; + for (HivePrivilegeInfo info : privileges) { + String raw = info.getPrivilege().getName(); + // Ranger sometimes appends qualifiers: "SELECT(ACCESS_CONDITIONAL)" or "SELECT something". + int sep = raw.indexOf('('); + if (sep < 0) { + sep = raw.indexOf(' '); + } + String privName = (sep < 0 ? raw : raw.substring(0, sep)).trim().toUpperCase(); + LOG.debug("Privilege {} for user {} on {}", privName, userName, privObj); + if (writePrivileges.contains(privName)) { + return AccessLevel.READ_WRITE; + } + if (READ_PRIVILEGES.contains(privName)) { + hasRead = true; + } + } + return hasRead ? AccessLevel.READ_ONLY : AccessLevel.NONE; + } catch (Exception e) { + LOG.warn("Failed to check privileges for user {} on {}", userName, privObj, e); + return AccessLevel.NONE; + } + } +} diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index d3ae82cb5725..8ebdcb594251 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -18,14 +18,23 @@ package org.apache.iceberg.rest; +import static org.apache.iceberg.rest.HMSPrivilegeHelper.AccessLevel; + import java.io.Closeable; +import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.ref.SoftReference; +import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -33,10 +42,14 @@ import javax.management.MBeanServer; import javax.management.ObjectName; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Ticker; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.conf.HiveConf; +import org.apache.hadoop.hive.metastore.RangerPrivilegeHelper; +import org.apache.hadoop.security.UserGroupInformation; import org.apache.iceberg.BaseMetadataTable; -import org.apache.iceberg.CachingCatalog; import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.MetadataTableType; import org.apache.iceberg.MetadataTableUtils; @@ -48,6 +61,7 @@ import org.apache.iceberg.catalog.SupportsNamespaces; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.catalog.ViewCatalog; +import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.hive.HiveCatalog; @@ -58,14 +72,63 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.github.benmanes.caffeine.cache.Ticker; - /** - * Class that wraps an Iceberg Catalog to cache tables. + * Caching wrapper around a {@link HiveCatalog} that adds two-level table caching and + * per-request authorization enforcement. + * + *

Table caching (L2 + L1)

+ *

L2 — Caffeine cache. The primary table store. Each {@link Table} object is keyed by + * its {@link TableIdentifier} and expires after the configured inactivity period + * ({@code ICEBERG_CATALOG_CACHE_EXPIRY}, in milliseconds). On a cache miss, the table is loaded + * from the underlying {@link HiveCatalog} and its current metadata location is recorded. + * Subsequent hits skip the HMS round-trip entirely.

+ * + *

L1 — LinkedHashMap recency guard. A small bounded map (default 32 entries, 3 s TTL; + * configurable via {@code hms.caching.catalog.l1.cache.size} and + * {@code hms.caching.catalog.l1.cache.ttl}) that tracks when each L2-cached table was last + * confirmed fresh. While the L1 entry is live, {@code loadTable} skips the metadata-location + * staleness check against HMS. Once the L1 entry expires, the next call re-validates the stored + * metadata location; if it has changed, the L2 entry is evicted ({@code onCacheInvalidate}) and + * a fresh load is performed. The L1 layer trades a small risk of serving a stale snapshot for a + * large reduction in HMS round-trips under repeated access to the same table.

+ * + *

Both cache levels are invalidated together by {@link #invalidateTable(TableIdentifier)}, + * which also evicts all derived {@link org.apache.iceberg.MetadataTableType metadata-table} + * entries that share the base identifier.

+ * + *

Authorization

+ *

Every table and view operation enforces an access-level check against the authenticated user + * (resolved via {@link org.apache.hadoop.security.UserGroupInformation#getCurrentUser()}). + * Authorization is performed by the configured {@link HMSPrivilegeHelper} + * (typically {@link org.apache.hadoop.hive.metastore.RangerPrivilegeHelper}). If no Ranger + * authorizer is configured the helper returns {@link HMSPrivilegeHelper.AccessLevel#NONE} for + * all requests, so access is denied rather than open by default.

+ * + *

Access levels are cached in a single Caffeine cache (configurable via + * {@code hms.caching.catalog.access.cache.size}, default 256) that expires entries after the same + * TTL as the table cache. The cache is keyed by {@link TableIdentifier}: table and view operations + * use the identifier directly; namespace operations use a synthetic + * {@code TableIdentifier(namespace, "*")} key — {@code "*"} is not a valid Hive identifier + * character, so there is no collision with real table entries.

+ *
    + *
  • {@link HMSPrivilegeHelper.AccessLevel#READ_ONLY READ_ONLY} is required for + * {@code loadTable}/{@code loadView}/{@code listTables}/{@code listViews}.
  • + *
  • {@link HMSPrivilegeHelper.AccessLevel#READ_WRITE READ_WRITE} is required for + * {@code dropTable}/{@code dropView}/{@code renameTable}/{@code renameView}/ + * {@code registerTable}/{@code buildTable}/{@code buildView}.
  • + *
+ *

Authorization entries are invalidated alongside their object — table-level on + * {@link #invalidateTable(TableIdentifier)}, namespace-level on + * {@link #dropNamespace(org.apache.iceberg.catalog.Namespace)}.

+ * + *

Observability

+ *

This class implements {@link HMSCachingCatalogMXBean} and registers itself with the platform + * MBean server under the name {@code org.apache.hive:type=IcebergRESTCatalog,name=<catalogName>} + * so that cache hit/miss counts and invalidation counts can be monitored via JMX.

*/ -public class HMSCachingCatalog extends CachingCatalog - implements SupportsNamespaces, ViewCatalog, HMSCachingCatalogMXBean, Closeable { - protected static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); +public final class HMSCachingCatalog + implements Catalog, SupportsNamespaces, ViewCatalog, HMSCachingCatalogMXBean, Closeable { + private static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); @TestOnly private static SoftReference cacheRef = new SoftReference<>(null); @@ -85,13 +148,12 @@ public HiveCatalog getCatalog() { return hiveCatalog; } - // The underlying HiveCatalog instance. + // The underlying HiveCatalog that this caching catalog wraps. private final HiveCatalog hiveCatalog; - // Duplicate because CachingCatalog doesn't expose the case sensitivity of the underlying catalog, - // which is needed for canonicalizing identifiers before caching. - private final boolean caseSensitive; - // The locator. + // A helper that locates the metadata location for a given base table identifier. private final MetadataLocator metadataLocator; + // An L2 table cache (Caffeine). + private final Cache tableCache; // An L1 small latency cache. // This is used to cache the last cached time for each table identifier, // so that we can skip location check for repeated access to the same table within a short period of time, @@ -101,7 +163,10 @@ public HiveCatalog getCatalog() { private final int l1Ttl; // The L1 cache size. private final int l1CacheSize; - + // Computes privileges for a given table identifier and user. + private final HMSPrivilegeHelper privilegeHelper; + // Unified authz cache: keyed by TableIdentifier for tables/views, or by namespaceIdent(ns) for namespaces. + private final Cache> accessLevelCache; // Metrics counters. private final AtomicLong cacheHitCount = new AtomicLong(0); private final AtomicLong cacheMissCount = new AtomicLong(0); @@ -111,19 +176,32 @@ public HiveCatalog getCatalog() { // L1 cache metrics: counted only when the L2 (Caffeine) cache already has the entry. private final AtomicLong l1CacheHitCount = new AtomicLong(0); private final AtomicLong l1CacheMissCount = new AtomicLong(0); - // JMX ObjectName under which this instance is registered (may be null if registration failed). private ObjectName jmxObjectName; + + /** + * Creates a new caching catalog that wraps the given HiveCatalog. + * @param catalog the underlying HiveCatalog + * @param expirationMs the expiration time for the L2 cache, in milliseconds + */ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { - this(catalog, expirationMs, /*caseSensitive*/ true); + this(catalog, expirationMs, RangerPrivilegeHelper.create(catalog.getConf())); } - public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, boolean caseSensitive) { - super(catalog, caseSensitive, expirationMs, Ticker.systemTicker()); + /** + * Creates a new caching catalog that wraps the given HiveCatalog. + * @param catalog the underlying HiveCatalog + * @param expirationMs the expiration time for the L2 cache, in milliseconds + * @param privilegeHelper the helper to compute access levels for tables and namespaces + */ + HMSCachingCatalog(HiveCatalog catalog, long expirationMs, HMSPrivilegeHelper privilegeHelper) { this.hiveCatalog = catalog; - this.caseSensitive = caseSensitive; this.metadataLocator = new MetadataLocator(catalog); + this.tableCache = Caffeine.newBuilder() + .expireAfterAccess(expirationMs, TimeUnit.MILLISECONDS) + .ticker(Ticker.systemTicker()) + .build(); Configuration conf = catalog.getConf(); if (HiveConf.getBoolVar(conf, HiveConf.ConfVars.HIVE_IN_TEST)) { // Only keep a reference to the latest cache for testing purpose, so that tests can manipulate the catalog. @@ -145,9 +223,105 @@ protected boolean removeEldestEntry(Map.Entry eldest) { l1Ttl = 0; l1CacheSize = 0; } + this.privilegeHelper = privilegeHelper; + // Covers both table/view and namespace entries; no need to be greater than the number of + // concurrent users × distinct objects, which is usually small (e.g., 256). + int accessLevelCacheSize = conf.getInt("hms.caching.catalog.access.cache.size", 256); + Caffeine accessCacheBuilder = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMillis(expirationMs)) + .ticker(Ticker.systemTicker()); + if (accessLevelCacheSize > 0) { + accessCacheBuilder.maximumSize(accessLevelCacheSize); + } + this.accessLevelCache = accessCacheBuilder.build(); + // Register this instance as a JMX MBean for monitoring. registerJmx(catalog.name()); } + private AccessLevel computeAccessLevel(TableIdentifier ident, String user) { + if (!privilegeHelper.isAvailable()) { + return AccessLevel.READ_WRITE; + } + try { + String dbName = ident.namespace().level(0); + String tableName = ident.name(); + return privilegeHelper.getAccessLevel(dbName, tableName, user); + } catch (Exception e) { + LOG.warn("Access level check failed for {}", ident, e); + return AccessLevel.NONE; + } + } + + private String currentUser() { + try { + return UserGroupInformation.getCurrentUser().getShortUserName(); + } catch (IOException e) { + LOG.warn("Failed to determine current user", e); + return null; + } + } + + private AccessLevel cachedAccessLevel(TableIdentifier ident) { + String user = currentUser(); + if (user == null) { + return AccessLevel.NONE; + } + ConcurrentMap perUser = accessLevelCache.get(ident, k -> new ConcurrentHashMap<>()); + return perUser.computeIfAbsent(user, u -> computeAccessLevel(ident, u)); + } + + private void checkReadAccess(TableIdentifier ident) { + if (cachedAccessLevel(ident) == AccessLevel.NONE) { + throw new ForbiddenException("Access denied on %s", ident); + } + } + + private void checkWriteAccess(TableIdentifier ident) { + if (cachedAccessLevel(ident) != AccessLevel.READ_WRITE) { + throw new ForbiddenException("Write access denied on %s", ident); + } + } + + private AccessLevel computeNamespaceAccessLevel(Namespace namespace, String user) { + if (namespace.isEmpty()) { + return AccessLevel.NONE; + } + if (!privilegeHelper.isAvailable()) { + return AccessLevel.READ_WRITE; + } + try { + return privilegeHelper.getNamespaceAccessLevel(namespace.level(0), user); + } catch (Exception e) { + LOG.warn("Namespace access level check failed for {}", namespace, e); + return AccessLevel.NONE; + } + } + + private TableIdentifier namespaceIdent(Namespace ns) { + return TableIdentifier.of(ns, "*"); + } + + private AccessLevel cachedNamespaceAccessLevel(Namespace namespace) { + String user = currentUser(); + if (user == null) { + return AccessLevel.NONE; + } + ConcurrentMap perUser = accessLevelCache.get(namespaceIdent(namespace), k -> new ConcurrentHashMap<>()); + return perUser.computeIfAbsent(user, u -> computeNamespaceAccessLevel(namespace, u)); + } + + private void checkNamespaceReadAccess(Namespace namespace) { + if (cachedNamespaceAccessLevel(namespace) == AccessLevel.NONE) { + throw new ForbiddenException("Access denied on namespace %s", namespace); + } + } + + private void checkNamespaceWriteAccess(Namespace namespace) { + if (cachedNamespaceAccessLevel(namespace) != AccessLevel.READ_WRITE) { + throw new ForbiddenException("Write access denied on namespace %s", namespace); + } + } + /** * Registers this instance as a JMX MBean. * @@ -176,7 +350,7 @@ private void registerJmx(String catalogName) { * * @param tid the table identifier to invalidate */ - protected void onCacheInvalidate(TableIdentifier tid) { + private void onCacheInvalidate(TableIdentifier tid) { long count = cacheInvalidateCount.incrementAndGet(); LOG.debug("Cache invalidate {}: {}", tid, count); } @@ -186,7 +360,7 @@ protected void onCacheInvalidate(TableIdentifier tid) { * * @param tid the table identifier */ - protected void onCacheLoad(TableIdentifier tid) { + private void onCacheLoad(TableIdentifier tid) { long count = cacheLoadCount.incrementAndGet(); LOG.debug("Cache load {}: {}", tid, count); } @@ -196,7 +370,7 @@ protected void onCacheLoad(TableIdentifier tid) { * * @param tid the table identifier */ - protected void onCacheHit(TableIdentifier tid) { + private void onCacheHit(TableIdentifier tid) { long count = cacheHitCount.incrementAndGet(); LOG.debug("Cache hit {} : {}", tid, count); } @@ -206,7 +380,7 @@ protected void onCacheHit(TableIdentifier tid) { * * @param tid the table identifier */ - protected void onCacheMiss(TableIdentifier tid) { + private void onCacheMiss(TableIdentifier tid) { long count = cacheMissCount.incrementAndGet(); LOG.debug("Cache miss {}: {}", tid, count); } @@ -216,7 +390,7 @@ protected void onCacheMiss(TableIdentifier tid) { * * @param tid the table identifier */ - protected void onCacheMetaLoad(TableIdentifier tid) { + private void onCacheMetaLoad(TableIdentifier tid) { long count = cacheMetaLoadCount.incrementAndGet(); LOG.debug("Cache meta-load {}: {}", tid, count); } @@ -227,7 +401,7 @@ protected void onCacheMetaLoad(TableIdentifier tid) { * * @param tid the table identifier */ - protected void onL1CacheHit(TableIdentifier tid) { + private void onL1CacheHit(TableIdentifier tid) { long count = l1CacheHitCount.incrementAndGet(); LOG.debug("L1 cache hit {}: {}", tid, count); } @@ -238,7 +412,7 @@ protected void onL1CacheHit(TableIdentifier tid) { * * @param tid the table identifier */ - protected void onL1CacheMiss(TableIdentifier tid) { + private void onL1CacheMiss(TableIdentifier tid) { long count = l1CacheMissCount.incrementAndGet(); LOG.debug("L1 cache miss {}: {}", tid, count); } @@ -330,34 +504,82 @@ private void unregisterJmx() { } @Override - public void createNamespace(Namespace namespace, Map map) { - hiveCatalog.createNamespace(namespace, map); + public String name() { + return hiveCatalog.name(); } @Override - public List listNamespaces(Namespace namespace) throws NoSuchNamespaceException { - return hiveCatalog.listNamespaces(namespace); + public List listTables(Namespace namespace) { + checkNamespaceReadAccess(namespace); + return hiveCatalog.listTables(namespace); + } + + @Override + public boolean dropTable(TableIdentifier identifier, boolean purge) { + checkWriteAccess(identifier); + boolean dropped = hiveCatalog.dropTable(identifier, purge); + invalidateTable(identifier); + return dropped; + } + + @Override + public void renameTable(TableIdentifier from, TableIdentifier to) { + checkWriteAccess(from); + hiveCatalog.renameTable(from, to); + invalidateTable(from); + } + + @Override + public Table registerTable(TableIdentifier identifier, String metadataFileLocation) { + checkWriteAccess(identifier); + Table registered = hiveCatalog.registerTable(identifier, metadataFileLocation); + invalidateTable(identifier); + return registered; + } + + @Override + public void invalidateTable(TableIdentifier ident) { + hiveCatalog.invalidateTable(ident); + TableIdentifier canonicalized = ident; + tableCache.invalidate(canonicalized); + tableCache.invalidateAll(metadataTableIdentifiers(canonicalized)); + l1Cache.remove(canonicalized); + accessLevelCache.invalidate(canonicalized); } /** - * Canonicalizes the given table identifier based on the case sensitivity of the underlying catalog. - * Copied from CachingCatalog that exposes it as private. - * @param tableIdentifier the table identifier to canonicalize - * @return the canonicalized table identifier + * Returns the identifiers of all metadata tables derived from the given base table identifier, + * in both upper-case and lower-case type-name forms so that eviction covers both variants. */ - private TableIdentifier canonicalizeIdentifier(TableIdentifier tableIdentifier) { - return this.caseSensitive ? tableIdentifier : tableIdentifier.toLowerCase(); + private List metadataTableIdentifiers(TableIdentifier identifier) { + MetadataTableType[] types = MetadataTableType.values(); + List result = new ArrayList<>(types.length * 2); + for (MetadataTableType type : types) { + result.add(TableIdentifier.parse(identifier + "." + type.name())); + result.add(TableIdentifier.parse(identifier + "." + type.name().toLowerCase(Locale.ROOT))); + } + return result; } @Override - public void invalidateTable(TableIdentifier ident) { - super.invalidateTable(ident); - l1Cache.remove(ident); + public void createNamespace(Namespace namespace, Map map) { + hiveCatalog.createNamespace(namespace, map); + } + + @Override + public List listNamespaces(Namespace namespace) throws NoSuchNamespaceException { + return hiveCatalog.listNamespaces(namespace); + } + + @Override + public void invalidateView(TableIdentifier identifier) { + hiveCatalog.invalidateView(identifier); } @Override public Table loadTable(final TableIdentifier identifier) { - final TableIdentifier canonicalized = canonicalizeIdentifier(identifier); + final TableIdentifier canonicalized = identifier; + checkReadAccess(canonicalized); final Table cachedTable = tableCache.getIfPresent(canonicalized); long now = System.currentTimeMillis(); if (cachedTable != null) { @@ -401,7 +623,6 @@ public Table loadTable(final TableIdentifier identifier) { } else { onCacheMiss(canonicalized); } - // The following code is copied from CachingCatalog.loadTable(), but with additional handling for L1 cache and stats. final Table table = tableCache.get(canonicalized, this::loadTableWithoutCache); if (table instanceof BaseMetadataTable) { // Cache underlying table: there must be a table named by the namespace (?) @@ -412,7 +633,7 @@ public Table loadTable(final TableIdentifier identifier) { if (originTable instanceof HasTableOperations tableOps) { TableOperations ops = tableOps.operations(); MetadataTableType type = MetadataTableType.from(canonicalized.name()); - // Defensive: CachingCatalog doesn't perform this check + // Defensive: MetadataTableType.from may return null for unknown names if (type != null) { Table metadataTable = MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); @@ -430,6 +651,11 @@ public Table loadTable(final TableIdentifier identifier) { return table; } + @Override + public boolean tableExists(TableIdentifier identifier) { + return metadataLocator.getLocation(identifier) != null; + } + private Table loadTableWithoutCache(TableIdentifier identifier) { return hiveCatalog.loadTable(identifier); } @@ -441,11 +667,13 @@ public Map loadNamespaceMetadata(Namespace namespace) throws NoS @Override public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyException { - List tables = listTables(namespace); - for (TableIdentifier ident : tables) { + // Use the underlying catalog directly to avoid the namespace read check for internal cache cleanup. + for (TableIdentifier ident : hiveCatalog.listTables(namespace)) { invalidateTable(ident); } - return hiveCatalog.dropNamespace(namespace); + boolean dropped = hiveCatalog.dropNamespace(namespace); + accessLevelCache.invalidate(namespaceIdent(namespace)); + return dropped; } @Override @@ -465,16 +693,19 @@ public boolean namespaceExists(Namespace namespace) { @Override public Catalog.TableBuilder buildTable(TableIdentifier identifier, Schema schema) { + checkNamespaceWriteAccess(identifier.namespace()); return hiveCatalog.buildTable(identifier, schema); } @Override public List listViews(Namespace namespace) { + checkNamespaceReadAccess(namespace); return hiveCatalog.listViews(namespace); } @Override public View loadView(TableIdentifier identifier) { + checkReadAccess(identifier); return hiveCatalog.loadView(identifier); } @@ -485,24 +716,22 @@ public boolean viewExists(TableIdentifier identifier) { @Override public ViewBuilder buildView(TableIdentifier identifier) { + checkNamespaceWriteAccess(identifier.namespace()); return hiveCatalog.buildView(identifier); } @Override public boolean dropView(TableIdentifier identifier) { + checkWriteAccess(identifier); return hiveCatalog.dropView(identifier); } @Override public void renameView(TableIdentifier from, TableIdentifier to) { + checkWriteAccess(from); hiveCatalog.renameView(from, to); } - @Override - public void invalidateView(TableIdentifier identifier) { - hiveCatalog.invalidateView(identifier); - } - @Override public void initialize(String name, Map properties) { hiveCatalog.initialize(name, properties); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.java new file mode 100644 index 000000000000..949a62431f4d --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.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.iceberg.rest; + +/** + * Interface for a helper that determines the access level a user has on a table or namespace. + */ +public interface HMSPrivilegeHelper { + + /** + * The level of access a user has on a table. + */ + enum AccessLevel { + NONE, + READ_ONLY, + READ_WRITE + } + + /** + * Whether the helper was successfully initialized with an authorizer. + */ + default boolean isAvailable() { + return false; + } + + /** + * Determines the access level a user has on a given table by calling + * the Ranger showPrivileges API directly. + * + * @param dbName the database name + * @param tableName the table name + * @param userName the user name + * @return the access level (NONE, READ_ONLY, or READ_WRITE) + */ + AccessLevel getAccessLevel(String dbName, String tableName, String userName); + + /** + * Determines the access level a user has on a namespace (database). + * + * @param dbName the database name + * @param userName the user name + * @return the access level (NONE, READ_ONLY, or READ_WRITE) + */ + AccessLevel getNamespaceAccessLevel(String dbName, String userName); +} diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java new file mode 100644 index 000000000000..83323112b460 --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java @@ -0,0 +1,402 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.iceberg.rest; + +import java.security.PrivilegedExceptionAction; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; +import org.apache.hadoop.hive.metastore.annotation.MetastoreCheckinTest; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.hadoop.security.UserGroupInformation; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +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.ForbiddenException; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.rest.HMSPrivilegeHelper.AccessLevel; +import org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension; +import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests that {@link HMSCachingCatalog} enforces access-level checks for every operation, and that + * the results are correctly cached and invalidated. + * + *

A {@link StubPrivilegeHelper} controls exactly which access level each (user, db, table) or + * (user, db) triple receives, and counts how many times the helper was actually queried so that + * caching behaviour can be verified. + */ +@Category(MetastoreCheckinTest.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TestHMSCachingCatalogAuthz { + + private static final long CACHE_EXPIRY_MS = 5 * 60 * 1_000L; + private static final String NS = "authz_test_ns"; + private static final Namespace NAMESPACE = Namespace.of(NS); + private static final String TABLE = "authz_test_table"; + private static final TableIdentifier TABLE_ID = TableIdentifier.of(NAMESPACE, TABLE); + private static final Schema SCHEMA = new Schema(); + + @RegisterExtension + private static final HiveRESTCatalogServerExtension SERVER = + HiveRESTCatalogServerExtension.builder(AuthType.NONE) + .configure(MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), + String.valueOf(CACHE_EXPIRY_MS)) + .configure("hive.in.test", "true") + .build(); + + private HiveCatalog hiveCatalog; + private StubPrivilegeHelper stub; + private HMSCachingCatalog catalog; + + @BeforeAll + void setupAll() { + HMSCachingCatalog serverCatalog = HMSCachingCatalog.getLatestCache(null); + Assertions.assertNotNull(serverCatalog, "HMSCachingCatalog must be initialized by the server"); + hiveCatalog = serverCatalog.getCatalog(); + } + + @BeforeEach + void setupEach() { + stub = new StubPrivilegeHelper(); + catalog = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, stub); + hiveCatalog.createNamespace(NAMESPACE); + } + + @AfterEach + void cleanup() { + try { hiveCatalog.dropTable(TABLE_ID, false); } catch (Exception ignored) {} + try { hiveCatalog.dropNamespace(NAMESPACE); } catch (Exception ignored) {} + // Do not call catalog.close() — it would unregister the JMX MBean that other test + // classes (e.g. TestHMSCachingCatalogStats) rely on via the server-side catalog. + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** Runs {@code action} as the given short user name and returns its result. */ + private static T as(String user, PrivilegedExceptionAction action) throws Exception { + return UserGroupInformation.createRemoteUser(user).doAs(action); + } + + // --------------------------------------------------------------------------- + // Table-level access checks + // --------------------------------------------------------------------------- + + @Test + void testLoadTableReadOnlyGranted() throws Exception { + Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + // We must get back the very table we created, not merely a non-null result. + Table loaded = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(loaded.name()).isEqualTo(created.name()); + assertThat(loaded.location()).isEqualTo(created.location()); + } + + @Test + void testLoadTableReadWriteGranted() throws Exception { + Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_WRITE); + + Table loaded = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(loaded.name()).isEqualTo(created.name()); + assertThat(loaded.location()).isEqualTo(created.location()); + } + + @Test + void testLoadTableDenied() { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + // alice has no grant → NONE + + assertThatThrownBy(() -> as("alice", () -> { catalog.loadTable(TABLE_ID); return null; })) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void testDropTableWriteGranted() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_WRITE); + + as("alice", () -> { catalog.dropTable(TABLE_ID); return null; }); + + // Table must be gone from HMS + assertThat(hiveCatalog.tableExists(TABLE_ID)).isFalse(); + } + + @Test + void testDropTableReadOnlyDenied() { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + assertThatThrownBy(() -> as("alice", () -> { catalog.dropTable(TABLE_ID); return null; })) + .isInstanceOf(ForbiddenException.class); + + // Table must still exist — the drop was vetoed + assertThat(hiveCatalog.tableExists(TABLE_ID)).isTrue(); + } + + @Test + void testDropTableDeniedWhenNoGrant() { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + + assertThatThrownBy(() -> as("alice", () -> { catalog.dropTable(TABLE_ID); return null; })) + .isInstanceOf(ForbiddenException.class); + } + + // --------------------------------------------------------------------------- + // Namespace-level access checks + // --------------------------------------------------------------------------- + + @Test + void testListTablesNamespaceReadGranted() throws Exception { + stub.grantNamespace("alice", NS, AccessLevel.READ_ONLY); + + // Must not throw; result may be empty + as("alice", () -> catalog.listTables(NAMESPACE)); + } + + @Test + void testListTablesNamespaceReadDenied() { + assertThatThrownBy(() -> as("alice", () -> catalog.listTables(NAMESPACE))) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void testCreateTableNamespaceWriteGranted() throws Exception { + stub.grantNamespace("alice", NS, AccessLevel.READ_WRITE); + + // buildTable checks namespace write access; the actual create goes to the underlying HiveCatalog + TableIdentifier newTable = TableIdentifier.of(NAMESPACE, "new_table"); + Table created = as("alice", () -> catalog.buildTable(newTable, SCHEMA).create()); + + // Confirm the created table was actually persisted and is the one we built. We read it back + // through the underlying HiveCatalog (loadTable would require a separate table-level grant). + assertThat(hiveCatalog.tableExists(newTable)).isTrue(); + assertThat(hiveCatalog.loadTable(newTable).location()).isEqualTo(created.location()); + hiveCatalog.dropTable(newTable, false); + } + + @Test + void testCreateTableNamespaceReadOnlyDenied() { + stub.grantNamespace("alice", NS, AccessLevel.READ_ONLY); + + TableIdentifier newTable = TableIdentifier.of(NAMESPACE, "new_table"); + assertThatThrownBy(() -> as("alice", () -> catalog.buildTable(newTable, SCHEMA).create())) + .isInstanceOf(ForbiddenException.class); + + assertThat(hiveCatalog.tableExists(newTable)).isFalse(); + } + + @Test + void testCreateTableNamespaceNoGrantDenied() { + TableIdentifier newTable = TableIdentifier.of(NAMESPACE, "new_table"); + assertThatThrownBy(() -> as("alice", () -> catalog.buildTable(newTable, SCHEMA).create())) + .isInstanceOf(ForbiddenException.class); + } + + // --------------------------------------------------------------------------- + // Caching and invalidation + // --------------------------------------------------------------------------- + + @Test + void testAccessLevelIsCachedBetweenCalls() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + as("alice", () -> catalog.loadTable(TABLE_ID)); + int countAfterFirst = stub.getCallCount(); + + as("alice", () -> catalog.loadTable(TABLE_ID)); + int countAfterSecond = stub.getCallCount(); + + assertThat(countAfterFirst).isEqualTo(1); + // The access level was cached; the helper must not have been called again. + assertThat(countAfterSecond).isEqualTo(1); + } + + @Test + void testInvalidateTableClearsAuthzCache() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(stub.getCallCount()).isEqualTo(1); + + catalog.invalidateTable(TABLE_ID); + + as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(stub.getCallCount()).isEqualTo(2); + } + + @Test + void testNamespaceAccessLevelIsCachedBetweenCalls() throws Exception { + stub.grantNamespace("alice", NS, AccessLevel.READ_ONLY); + + as("alice", () -> catalog.listTables(NAMESPACE)); + int countAfterFirst = stub.getCallCount(); + + as("alice", () -> catalog.listTables(NAMESPACE)); + int countAfterSecond = stub.getCallCount(); + + assertThat(countAfterFirst).isEqualTo(1); + assertThat(countAfterSecond).isEqualTo(1); + } + + @Test + void testDifferentUsersGetIndependentAccessLevels() throws Exception { + Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + // bob has no grant + + Table loaded = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(loaded.location()).isEqualTo(created.location()); + assertThatThrownBy(() -> as("bob", () -> { catalog.loadTable(TABLE_ID); return null; })) + .isInstanceOf(ForbiddenException.class); + } + + // --------------------------------------------------------------------------- + // Metadata cache behavior + // --------------------------------------------------------------------------- + + @Test + void testL2CacheReturnsSameTableInstance() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + Table first = as("alice", () -> catalog.loadTable(TABLE_ID)); + Table second = as("alice", () -> catalog.loadTable(TABLE_ID)); + + // Caffeine stores object references; a cache hit returns the identical instance. + assertThat(first).isSameAs(second); + } + + @Test + void testInvalidateTableForcesReload() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + Table before = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(before.currentSnapshot()).isNull(); // fresh table, no snapshot yet + + // Advance the underlying table's metadata location by committing a new snapshot. + Table raw = hiveCatalog.loadTable(TABLE_ID); + DataFile file = DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(raw.location() + "/data/file-0.parquet") + .withFileSizeInBytes(1024).withRecordCount(1).build(); + raw.newAppend().appendFile(file).commit(); + + // Explicit invalidation evicts L2, L1, and authz caches. + catalog.invalidateTable(TABLE_ID); + + Table after = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(after).isNotSameAs(before); + assertThat(after.currentSnapshot()).isNotNull(); + assertThat(after.currentSnapshot().snapshotId()).isEqualTo(raw.currentSnapshot().snapshotId()); + } + + @Test + void testDropTableEvictsCache() throws Exception { + // Create a table with a snapshot so the cached version has observable state. + hiveCatalog.createTable(TABLE_ID, SCHEMA); + Table raw = hiveCatalog.loadTable(TABLE_ID); + DataFile file = DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(raw.location() + "/data/file-0.parquet") + .withFileSizeInBytes(1024).withRecordCount(1).build(); + raw.newAppend().appendFile(file).commit(); + + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_WRITE); + + Table cached = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(cached.currentSnapshot()).isNotNull(); + + // Drop clears L2, L1, and authz caches via invalidateTable. + as("alice", () -> { catalog.dropTable(TABLE_ID); return null; }); + + // Recreate a fresh empty table (no snapshot) and reload. + hiveCatalog.createTable(TABLE_ID, SCHEMA); + + Table reloaded = as("alice", () -> catalog.loadTable(TABLE_ID)); + assertThat(reloaded).isNotSameAs(cached); + assertThat(reloaded.currentSnapshot()).isNull(); + } + + // --------------------------------------------------------------------------- + // Stub privilege helper + // --------------------------------------------------------------------------- + + /** + * Configurable stub for {@link HMSPrivilegeHelper} that records how many times it was queried. + * Grants are registered with {@link #grantTable} / {@link #grantNamespace}; any unregistered + * combination returns {@link AccessLevel#NONE}. + */ + static class StubPrivilegeHelper implements HMSPrivilegeHelper { + + private final Map tableGrants = new ConcurrentHashMap<>(); + private final Map namespaceGrants = new ConcurrentHashMap<>(); + private final AtomicInteger callCount = new AtomicInteger(); + + void grantTable(String user, String db, String table, AccessLevel level) { + tableGrants.put(user + "/" + db + "." + table, level); + } + + void grantNamespace(String user, String db, AccessLevel level) { + namespaceGrants.put(user + "/" + db, level); + } + + int getCallCount() { + return callCount.get(); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { + callCount.incrementAndGet(); + return tableGrants.getOrDefault(userName + "/" + dbName + "." + tableName, AccessLevel.NONE); + } + + @Override + public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { + callCount.incrementAndGet(); + return namespaceGrants.getOrDefault(userName + "/" + dbName, AccessLevel.NONE); + } + } +} diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index 33317f0b30ab..6e441096450f 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -157,23 +157,41 @@ private void invokeJmxOperation(String operationName) throws Exception { *

  • Assert JMX counter deltas match expectations.
  • * */ + /** + * Counter states for the four {@code loadTable} calls in {@link #testCacheCountersAreUpdated}: + *
    +   *   Call 1 – cold L2 miss : onCacheMiss  + onCacheLoad               → miss=1, load=1
    +   *   Call 2 – L1 hit       : onL1CacheHit + onCacheHit                 → l1Hit=1, hit=1
    +   *   Call 3 – L1 hit       : onL1CacheHit + onCacheHit                 → l1Hit=2, hit=2
    +   *   [sleep >L1 TTL; mutated table has new METADATA_LOCATION in HMS]
    +   *   Call 4 – L1 expired,
    +   *            location mismatch: onL1CacheMiss + onCacheInvalidate
    +   *                             + onCacheLoad                           → l1Miss=1, invalidate=1, load=2
    +   * 
    + * Note: call 4 does NOT fire {@code onCacheMiss}: that counter only increments when the L2 + * {@code getIfPresent} returns null (the else-branch). The location-mismatch path goes through the + * if-branch, evicts L2 internally, and falls straight to {@code tableCache.get} + {@code onCacheLoad}. + */ @Test void testCacheCountersAreUpdated() throws Exception { // -- JMX baseline ----------------------------------------------------------- - long baseHit = jmxLong("CacheHitCount"); - long baseMiss = jmxLong("CacheMissCount"); - long baseLoad = jmxLong("CacheLoadCount"); - long baseL1Hit = jmxLong("L1CacheHitCount"); + long baseHit = jmxLong("CacheHitCount"); + long baseMiss = jmxLong("CacheMissCount"); + long baseLoad = jmxLong("CacheLoadCount"); + long baseL1Hit = jmxLong("L1CacheHitCount"); + long baseL1Miss = jmxLong("L1CacheMissCount"); // -- exercise the cache ----------------------------------------------------- var db = Namespace.of("caching_stats_test_db"); var tableId = TableIdentifier.of(db, "caching_stats_test_table"); catalog.createNamespace(db); - catalog.createTable(tableId, new Schema()); + Table created = catalog.createTable(tableId, new Schema()); - // First load → cache miss + load - catalog.loadTable(tableId); + // First load → cache miss + load; must return the table we just created. + Table firstLoad = catalog.loadTable(tableId); + Assertions.assertEquals(created.location(), firstLoad.location(), + "First load must return the table we just created"); // Second load → L1 hit (within TTL, HMS location check skipped) catalog.loadTable(tableId); // Third load → L1 hit @@ -189,34 +207,36 @@ void testCacheCountersAreUpdated() throws Exception { table.newAppend().appendFile(dataFile).commit(); long baseInvalidate = jmxLong("CacheInvalidateCount"); - // The L1 cache has a 3-second default TTL; wait for entries to expire. - Thread.sleep(3_000); + // Default L1 TTL is 3 000 ms; sleep 3 500 ms to ensure the entry is expired. + Thread.sleep(3_500); // Fourth load → L1 miss + cache invalidation + reload - catalog.loadTable(tableId); + Table reloaded = catalog.loadTable(tableId); - // -- JMX assertions --------------------------------------------------------- - long deltaHit = jmxLong("CacheHitCount") - baseHit; - long deltaMiss = jmxLong("CacheMissCount") - baseMiss; - long deltaLoad = jmxLong("CacheLoadCount") - baseLoad; + // -- JMX counter assertions (exact values; see Javadoc above for derivation) - + long deltaHit = jmxLong("CacheHitCount") - baseHit; + long deltaMiss = jmxLong("CacheMissCount") - baseMiss; + long deltaLoad = jmxLong("CacheLoadCount") - baseLoad; long deltaInvalidate = jmxLong("CacheInvalidateCount") - baseInvalidate; - long deltaL1Hit = jmxLong("L1CacheHitCount") - baseL1Hit; - long deltaL1Miss = jmxLong("L1CacheMissCount"); // absolute value is fine for L1 miss - - Assertions.assertTrue(deltaMiss >= 1, - "Expected at least 1 cache miss (first loadTable), but delta was: " + deltaMiss); - Assertions.assertTrue(deltaLoad >= 2, - "Expected at least 2 cache loads (initial + post-invalidation reload), but delta was: " + deltaLoad); - Assertions.assertTrue(deltaHit >= 2, - "Expected at least 2 cache hits (second + third loadTable), but delta was: " + deltaHit); - Assertions.assertTrue(deltaInvalidate >= 1, - "Expected at least 1 cache invalidation (metadata location changed), but delta was: " + deltaInvalidate); - - // L1 hits: the 2nd and 3rd loadTable calls should have been served by L1. - Assertions.assertTrue(deltaL1Hit >= 2, - "Expected at least 2 L1 cache hits (rapid successive loads within TTL), but delta was: " + deltaL1Hit); - // L1 miss: at least the fourth load (after TTL expiry) must have missed L1. - Assertions.assertTrue(deltaL1Miss >= 1, - "Expected at least 1 L1 cache miss (after TTL expiry), but was: " + deltaL1Miss); + long deltaL1Hit = jmxLong("L1CacheHitCount") - baseL1Hit; + long deltaL1Miss = jmxLong("L1CacheMissCount") - baseL1Miss; + + Assertions.assertEquals(1L, deltaMiss, + "Expected exactly 1 cache miss (cold load on call 1), but delta was: " + deltaMiss); + Assertions.assertEquals(2L, deltaLoad, + "Expected exactly 2 cache loads (call 1 + post-invalidation call 4), but delta was: " + deltaLoad); + Assertions.assertEquals(2L, deltaHit, + "Expected exactly 2 cache hits (calls 2 and 3), but delta was: " + deltaHit); + Assertions.assertEquals(1L, deltaInvalidate, + "Expected exactly 1 cache invalidation (metadata location changed on call 4), but delta was: " + deltaInvalidate); + Assertions.assertEquals(2L, deltaL1Hit, + "Expected exactly 2 L1 hits (calls 2 and 3, within TTL), but delta was: " + deltaL1Hit); + Assertions.assertEquals(1L, deltaL1Miss, + "Expected exactly 1 L1 miss (call 4, after TTL expiry), but delta was: " + deltaL1Miss); + + // The reloaded table must reflect the new snapshot created by the append above; + // this confirms the staleness-detection path returned fresh data, not the stale cache entry. + Assertions.assertNotNull(reloaded.currentSnapshot(), + "Staleness detection must have reloaded the table with its new snapshot"); // Rate attributes must be valid ratios in [0.0, 1.0]. double hitRate = jmxDouble("CacheHitRate"); @@ -244,8 +264,10 @@ void testJmxResetCacheStats() throws Exception { var db = Namespace.of("jmx_reset_test_db"); var tableId = TableIdentifier.of(db, "jmx_reset_test_table"); catalog.createNamespace(db); - catalog.createTable(tableId, new Schema()); - catalog.loadTable(tableId); // miss + load + Table created = catalog.createTable(tableId, new Schema()); + Table loaded = catalog.loadTable(tableId); // miss + load + Assertions.assertEquals(created.location(), loaded.location(), + "Warm-up load must return the table we just created"); catalog.loadTable(tableId); // hit (L1 hit on the fast path) // Sanity: at least one counter must be non-zero before the reset. From edc538238900da98b2de5524aa6aa6dfc617b6e8 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 09:44:14 +0200 Subject: [PATCH 09/20] HIVE-29035: address REST-catalog caching review comments - Fail-closed: don't override the privilege helper's NONE with READ_WRITE when !isAvailable(). - Authorize metadata tables (db.tbl.snapshots) against their base table (db.tbl), not a same-named decoy. - loadTable throws NoSuchTableException on a dropped table instead of serving the stale cached instance. - MetadataLocator.getLocation returns null (not throws) for a missing db/catalog, so null uniformly means not-found. - Guard L1 recency-guard writes so they no-op when L1 is disabled (empty map no longer throws). - Log JMX registration failure at error, not warn. - Fix class javadoc to the real MBean ObjectName and note catalog.name() == CATALOG_DEFAULT. - Tests: fail-closed denial, metadata-table authz, L1 disabled, dropped-table reload. --- .../apache/iceberg/hive/MetadataLocator.java | 12 ++- .../iceberg/rest/HMSCachingCatalog.java | 80 ++++++++++++---- .../rest/TestHMSCachingCatalogAuthz.java | 95 +++++++++++++++++++ 3 files changed, 162 insertions(+), 25 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index c09212feb363..19e1ce966993 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -30,7 +30,6 @@ import org.apache.iceberg.ClientPool; import org.apache.iceberg.MetadataTableType; import org.apache.iceberg.catalog.TableIdentifier; -import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.thrift.TException; /** @@ -59,8 +58,9 @@ public HiveCatalog getCatalog() { * not a metadata table. *

    This uses the Thrift API to fetch the table parameters, which is more efficient than fetching the entire table object.

    * @param identifier the identifier of the metadata table to fetch the location for - * @return the location of the metadata table, or null if the table does not exist or is not a metadata table - * @throws NoSuchTableException if the table does not exist + * @return the location of the metadata table, or null if the table (or its database/catalog) does + * not exist, or the identifier is not a valid (metadata) table identifier + * @throws RuntimeException if the HMS lookup fails for any reason other than the object not existing */ public String getLocation(TableIdentifier identifier) { final ClientPool clients = catalog.clientPool(); @@ -89,9 +89,11 @@ public String getLocation(TableIdentifier identifier) { } return null; } catch (NoSuchObjectException e) { - // NoSuchObjectException is a TException subclass that HMS may raise for an unknown database or catalog. + // NoSuchObjectException is a TException subclass HMS raises for an unknown database or catalog. + // Like an empty getTables result, it means the object does not exist, so we return null and let + // callers treat null uniformly as not-found (matching the missing-table case above). LOGGER.debug("Table {} not found: {}", baseTableIdentifier, e.getMessage()); - throw new NoSuchTableException(e, "Table %s not found: %s", baseTableIdentifier, e.getMessage()); + return null; } catch (TException e) { LOGGER.warn("Table {} parameters fetch failed: {}", baseTableIdentifier, e.getMessage()); throw new RuntimeException("Failed to fetch table parameters for " + baseTableIdentifier, e); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 8ebdcb594251..0aa90d6f45bc 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -64,6 +64,7 @@ import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; +import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.hive.HiveCatalog; import org.apache.iceberg.hive.MetadataLocator; import org.apache.iceberg.view.View; @@ -123,8 +124,10 @@ * *

    Observability

    *

    This class implements {@link HMSCachingCatalogMXBean} and registers itself with the platform - * MBean server under the name {@code org.apache.hive:type=IcebergRESTCatalog,name=<catalogName>} - * so that cache hit/miss counts and invalidation counts can be monitored via JMX.

    + * MBean server under the name {@code org.apache.iceberg.rest:type=HMSCachingCatalog,name=<catalogName>} + * so that cache hit/miss counts and invalidation counts can be monitored via JMX. The + * {@code catalogName} is {@link org.apache.iceberg.catalog.Catalog#name()} of the wrapped catalog + * (the metastore's configured default catalog name), sanitized for use in an {@link ObjectName}.

    */ public final class HMSCachingCatalog implements Catalog, SupportsNamespaces, ViewCatalog, HMSCachingCatalogMXBean, Closeable { @@ -234,14 +237,17 @@ protected boolean removeEldestEntry(Map.Entry eldest) { accessCacheBuilder.maximumSize(accessLevelCacheSize); } this.accessLevelCache = accessCacheBuilder.build(); - // Register this instance as a JMX MBean for monitoring. + // Register this instance as a JMX MBean for monitoring. The catalog was initialized with the + // metastore's CATALOG_DEFAULT name (see HMSCatalogFactory), so catalog.name() already yields + // that value; using it directly keeps this class free of a Configuration/MetastoreConf + // dependency and reflects the actual identity of the wrapped catalog. registerJmx(catalog.name()); } private AccessLevel computeAccessLevel(TableIdentifier ident, String user) { - if (!privilegeHelper.isAvailable()) { - return AccessLevel.READ_WRITE; - } + // Do not short-circuit on !isAvailable(): the pass-through helpers already return READ_WRITE + // when authorization is intentionally disabled, while a helper whose authorizer failed to + // initialize returns NONE (fail-closed). Overriding either here would open access. try { String dbName = ident.namespace().level(0); String tableName = ident.name(); @@ -252,6 +258,22 @@ private AccessLevel computeAccessLevel(TableIdentifier ident, String user) { } } + /** + * Resolves the identifier used for authorization. A metadata table (e.g. {@code db.tbl.snapshots}) + * must be authorized against its base table ({@code db.tbl}); otherwise a user granted on an + * unrelated table that happens to share the metadata-type name (e.g. {@code db.snapshots}) could + * read the metadata table without access to the table it derives from. + */ + private TableIdentifier authzIdentifier(TableIdentifier identifier) { + Namespace ns = identifier.namespace(); + if (ns.levels().length >= 2 && MetadataTableType.from(identifier.name()) != null) { + // TableIdentifier.of(String...) treats the last level as the table name, so passing the + // metadata table's namespace levels ([db, tbl]) yields the base table identifier (db.tbl). + return TableIdentifier.of(ns.levels()); + } + return identifier; + } + private String currentUser() { try { return UserGroupInformation.getCurrentUser().getShortUserName(); @@ -286,9 +308,7 @@ private AccessLevel computeNamespaceAccessLevel(Namespace namespace, String user if (namespace.isEmpty()) { return AccessLevel.NONE; } - if (!privilegeHelper.isAvailable()) { - return AccessLevel.READ_WRITE; - } + // See computeAccessLevel: never override the helper's decision based on availability. try { return privilegeHelper.getNamespaceAccessLevel(namespace.level(0), user); } catch (Exception e) { @@ -341,7 +361,7 @@ private void registerJmx(String catalogName) { this.jmxObjectName = name; LOG.info("Registered JMX MBean: {}", name); } catch (JMException e) { - LOG.warn("Failed to register JMX MBean for HMSCachingCatalog", e); + LOG.error("Failed to register JMX MBean for HMSCachingCatalog", e); } } @@ -543,10 +563,28 @@ public void invalidateTable(TableIdentifier ident) { TableIdentifier canonicalized = ident; tableCache.invalidate(canonicalized); tableCache.invalidateAll(metadataTableIdentifiers(canonicalized)); - l1Cache.remove(canonicalized); + l1Invalidate(canonicalized); accessLevelCache.invalidate(canonicalized); } + /** + * Records {@code now} as the last time the given identifier was confirmed fresh in the L1 + * recency guard. No-op when L1 is disabled: in that case {@link #l1Cache} is an immutable empty + * map, so writing to it would throw {@link UnsupportedOperationException}. + */ + private void l1MarkFresh(TableIdentifier ident, long now) { + if (l1Ttl > 0) { + l1Cache.put(ident, now); + } + } + + /** Evicts the given identifier from the L1 recency guard. No-op when L1 is disabled. */ + private void l1Invalidate(TableIdentifier ident) { + if (l1Ttl > 0) { + l1Cache.remove(ident); + } + } + /** * Returns the identifiers of all metadata tables derived from the given base table identifier, * in both upper-case and lower-case type-name forms so that eviction covers both variants. @@ -579,7 +617,7 @@ public void invalidateView(TableIdentifier identifier) { @Override public Table loadTable(final TableIdentifier identifier) { final TableIdentifier canonicalized = identifier; - checkReadAccess(canonicalized); + checkReadAccess(authzIdentifier(canonicalized)); final Table cachedTable = tableCache.getIfPresent(canonicalized); long now = System.currentTimeMillis(); if (cachedTable != null) { @@ -594,7 +632,7 @@ public Table loadTable(final TableIdentifier identifier) { onCacheHit(canonicalized); return cachedTable; } else { - l1Cache.remove(canonicalized); + l1Invalidate(canonicalized); onL1CacheMiss(canonicalized); } } else { @@ -603,16 +641,18 @@ public Table loadTable(final TableIdentifier identifier) { // If the table is no longer in L1 cache, we need to check the location. final String location = metadataLocator.getLocation(canonicalized); if (location == null) { - LOG.debug("Table {} has no location, returning cached table without location", canonicalized); - onCacheHit(canonicalized); - l1Cache.put(canonicalized, now); - return cachedTable; + // A null location means the table no longer exists in HMS. The cached instance is stale and + // its metadata/manifests are highly likely to be deleted, so we must not serve it: evict the + // entry and signal not-found rather than returning a ghost table. + LOG.debug("Table {} no longer exists in HMS, evicting stale cache entry", canonicalized); + invalidateTable(canonicalized); + throw new NoSuchTableException("Table does not exist: %s", canonicalized); } String cachedLocation = cachedTable instanceof HasTableOperations tableOps ? tableOps.operations().current().metadataFileLocation() : null; if (location.equals(cachedLocation)) { onCacheHit(canonicalized); - l1Cache.put(canonicalized, now); + l1MarkFresh(canonicalized, now); return cachedTable; } else { LOG.debug("Invalidate table {}, cached {} != actual {}", canonicalized, cachedLocation, location); @@ -638,7 +678,7 @@ public Table loadTable(final TableIdentifier identifier) { Table metadataTable = MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); tableCache.put(canonicalized, metadataTable); - l1Cache.put(canonicalized, now); + l1MarkFresh(canonicalized, now); onCacheMetaLoad(canonicalized); LOG.debug("Loaded metadata table: {} for origin table: {}", canonicalized, originTableIdentifier); // Return the metadata table instead of the original table @@ -646,7 +686,7 @@ public Table loadTable(final TableIdentifier identifier) { } } } - l1Cache.put(canonicalized, now); + l1MarkFresh(canonicalized, now); onCacheLoad(canonicalized); return table; } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java index 83323112b460..4db71589674a 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java @@ -36,6 +36,7 @@ import org.apache.iceberg.catalog.Namespace; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ForbiddenException; +import org.apache.iceberg.exceptions.NoSuchTableException; import org.apache.iceberg.hive.HiveCatalog; import org.apache.iceberg.rest.HMSPrivilegeHelper.AccessLevel; import org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension; @@ -355,6 +356,100 @@ void testDropTableEvictsCache() throws Exception { assertThat(reloaded.currentSnapshot()).isNull(); } + // --------------------------------------------------------------------------- + // Fail-closed and metadata-table authorization + // --------------------------------------------------------------------------- + + @Test + void testUnavailableHelperReturningNoneDenies() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + // A helper whose authorizer failed to initialize: not available, but fail-closed (NONE). + // The catalog must honour that NONE and not fall back to READ_WRITE. + HMSPrivilegeHelper failClosed = new HMSPrivilegeHelper() { + @Override public boolean isAvailable() { return false; } + @Override public AccessLevel getAccessLevel(String db, String table, String user) { + return AccessLevel.NONE; + } + @Override public AccessLevel getNamespaceAccessLevel(String db, String user) { + return AccessLevel.NONE; + } + }; + HMSCachingCatalog failClosedCatalog = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, failClosed); + + assertThatThrownBy(() -> as("alice", () -> { failClosedCatalog.loadTable(TABLE_ID); return null; })) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void testMetadataTableAuthorizedAgainstBaseTable() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + TableIdentifier metaId = TableIdentifier.of(Namespace.of(NS, TABLE), "snapshots"); + + // A grant on a decoy table that merely shares the metadata-type name must NOT leak access + // to the metadata table, which derives from TABLE_ID. + stub.grantTable("alice", NS, "snapshots", AccessLevel.READ_ONLY); + assertThatThrownBy(() -> as("alice", () -> { catalog.loadTable(metaId); return null; })) + .isInstanceOf(ForbiddenException.class); + + // A grant on the base table authorizes its metadata tables (distinct user to avoid the + // cached NONE from the denial above). + stub.grantTable("bob", NS, TABLE, AccessLevel.READ_ONLY); + Table snapshots = as("bob", () -> catalog.loadTable(metaId)); + assertThat(snapshots).isNotNull(); + } + + @Test + void testLoadTableWithL1CacheDisabled() throws Exception { + Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + // Disable the L1 recency guard; its backing map is then an immutable empty map, so any write + // to it would throw. The second load exercises the L2-hit path that records L1 freshness. + var conf = hiveCatalog.getConf(); + int prevSize = conf.getInt("hms.caching.catalog.l1.cache.size", 32); + int prevTtl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); + conf.setInt("hms.caching.catalog.l1.cache.size", 0); + try { + HMSCachingCatalog noL1 = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, stub); + Table first = as("alice", () -> noL1.loadTable(TABLE_ID)); + Table second = as("alice", () -> noL1.loadTable(TABLE_ID)); + assertThat(first.location()).isEqualTo(created.location()); + assertThat(second.location()).isEqualTo(created.location()); + } finally { + conf.setInt("hms.caching.catalog.l1.cache.size", prevSize); + conf.setInt("hms.caching.catalog.l1.cache.ttl", prevTtl); + } + } + + @Test + void testReloadOfDroppedTableThrowsNoSuchTable() throws Exception { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); + + // Disable the L1 recency guard so the second load re-checks the HMS location instead of + // short-circuiting on L1 freshness. + var conf = hiveCatalog.getConf(); + int prevSize = conf.getInt("hms.caching.catalog.l1.cache.size", 32); + conf.setInt("hms.caching.catalog.l1.cache.size", 0); + try { + HMSCachingCatalog noL1 = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, stub); + + // Warm the L2 cache with the table. + Table loaded = as("alice", () -> noL1.loadTable(TABLE_ID)); + assertThat(loaded).isNotNull(); + + // Drop the table straight through the underlying catalog so noL1's L2 entry is left stale. + hiveCatalog.dropTable(TABLE_ID, false); + + // Reloading must not serve the ghost: the null HMS location evicts the entry and signals + // not-found (the cached alice/READ_ONLY authz decision still passes, so we reach the cache path). + assertThatThrownBy(() -> as("alice", () -> { noL1.loadTable(TABLE_ID); return null; })) + .isInstanceOf(NoSuchTableException.class); + } finally { + conf.setInt("hms.caching.catalog.l1.cache.size", prevSize); + } + } + // --------------------------------------------------------------------------- // Stub privilege helper // --------------------------------------------------------------------------- From 6ad4485ed96c1904f6b3c9a49c171b6ca8b5e2c3 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 10:29:55 +0200 Subject: [PATCH 10/20] HIVE-29035: reduce HMSCachingCatalog to a pure cache Roll back the AccessLevel-based authorization recently added to HMSCachingCatalog: remove the authz fields, methods, and per-operation guards, delete HMSPrivilegeHelper and RangerPrivilegeHelper, and drop the 3-arg constructor. L1/L2 caching, the JMX MBean, and dropped-table -> NoSuchTableException are unchanged. Per-operation authorization belongs in IcebergAuthorizer, which already does it right for stage-create; extending it to the other operations is deferred to a follow-up PR. Until then, writes and cache-miss reads are authorized by HMS and stage-create by IcebergAuthorizer; only cache-hit reads are unchecked at the catalog level, which the follow-up closes. Tests: replaced TestHMSCachingCatalogAuthz with TestHMSCachingCatalogCache (pure-cache cases, 2-arg constructor). --- .../hive/metastore/RangerPrivilegeHelper.java | 258 --------- .../iceberg/rest/HMSCachingCatalog.java | 177 +------ .../iceberg/rest/HMSPrivilegeHelper.java | 62 --- .../rest/TestHMSCachingCatalogAuthz.java | 497 ------------------ .../rest/TestHMSCachingCatalogCache.java | 203 +++++++ 5 files changed, 205 insertions(+), 992 deletions(-) delete mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java delete mode 100644 standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.java delete mode 100644 standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java create mode 100644 standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java deleted file mode 100644 index 1fd7e78a9cfe..000000000000 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/hadoop/hive/metastore/RangerPrivilegeHelper.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.hadoop.hive.metastore; - -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.ql.metadata.HiveUtils; -import org.apache.hadoop.hive.ql.security.HiveAuthenticationProvider; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthorizerFactory; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthorizer; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthzSessionContext; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveMetastoreClientFactoryImpl; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeInfo; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject.HivePrivilegeObjectType; -import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrincipal; -import org.apache.iceberg.rest.HMSPrivilegeHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Set; - -/** - * Implements {@link HMSPrivilegeHelper} by calling Ranger's {@code showPrivileges} API directly, - * without routing through the Hive metastore Thrift endpoint. - * - *

    Initialization

    - * {@link #create(Configuration)} reads {@code HIVE_AUTHORIZATION_MANAGER} from the supplied - * configuration to locate a {@link HiveAuthorizerFactory}. Three outcomes are possible: - *
      - *
    • Factory found – a fully wired {@code RangerPrivilegeHelper} is returned and - * {@link #isAvailable()} returns {@code true}.
    • - *
    • No factory configured – a pass-through helper that grants {@code READ_WRITE} - * on every object is returned and {@link #isAvailable()} returns {@code false}.
    • - *
    • Initialization exception – a {@code RangerPrivilegeHelper} with a {@code null} - * authorizer is returned; {@link #isAvailable()} returns {@code false} and every check - * returns {@link HMSPrivilegeHelper.AccessLevel#NONE}.
    • - *
    - * - *

    Privilege mapping

    - * Both {@link #getAccessLevel} and {@link #getNamespaceAccessLevel} delegate to the shared - * {@link #queryPrivileges} method. The algorithm scans the privilege list returned by - * {@link HiveAuthorizer#showPrivileges}: - *
      - *
    1. If any privilege is in the object's write set, return {@code READ_WRITE} - * immediately (short-circuit).
    2. - *
    3. If any privilege is in the read set, set a read flag.
    4. - *
    5. After all privileges are scanned, return {@code READ_ONLY} if the read flag is set, - * otherwise {@code NONE}.
    6. - *
    - * Ranger sometimes appends qualifiers to privilege names (e.g. {@code "SELECT(ACCESS_CONDITIONAL)"}). - * These are stripped before the comparison. - * - *

    Privilege sets (aligned with Ranger's Hive access-type model):

    - *
      - *
    • Read (shared) – {@code SELECT} (SQL standard), {@code READ} (Ranger data-plane alias)
    • - *
    • Table / view write (DML / data-plane) – {@code UPDATE} (Ranger's single - * data-mutation access type, covering insert/update/delete), {@code WRITE} (Ranger - * data-plane write alias), {@code ALL}. DDL privileges ({@code ALTER}, {@code DROP}) are - * authorized at the namespace level, not per-table.
    • - *
    • Namespace (database) write (DDL) – {@code CREATE}, {@code ALTER}, {@code DROP}, - * {@code ALL}. Data-plane grants ({@code UPDATE}, {@code WRITE}) are table-scoped and do - * not imply DDL access on the namespace.
    • - *
    - * - *

    Any exception thrown by the Ranger API is caught and logged; the result is {@code NONE}.

    - */ -public class RangerPrivilegeHelper implements HMSPrivilegeHelper { - private static final Logger LOG = LoggerFactory.getLogger(RangerPrivilegeHelper.class); - - // Privileges that imply read access on any object type. - // SELECT is the SQL-standard read privilege; READ is Ranger's data-plane alias. - private static final Set READ_PRIVILEGES = Set.of("SELECT", "READ"); - // Privileges that grant READ_WRITE at the table / view level (DML / data-plane only). - // Ranger's Hive access-type model uses UPDATE to cover all data mutation (insert/update/delete); - // there are no separate INSERT or DELETE access types in Ranger. WRITE is Ranger's data-plane - // write alias (parallel to READ). DDL privileges (ALTER, DROP) are authorized at the namespace - // level, not per-table, so they are intentionally absent here. - private static final Set TABLE_WRITE_PRIVILEGES = - Set.of("UPDATE", "WRITE", "ALL"); - // Privileges that grant READ_WRITE at the namespace (database) level (DDL). - // CREATE/ALTER/DROP are the DDL operations authorized at the database level (including for the - // tables it contains). UPDATE/WRITE are table-scoped data-plane grants and do not belong here. - private static final Set NAMESPACE_WRITE_PRIVILEGES = - Set.of("CREATE", "ALTER", "DROP", "ALL"); - - // The Ranger authorizer instance, or null if initialization failed. - private final HiveAuthorizer authorizer; - - protected RangerPrivilegeHelper(HiveAuthorizer auth) { - this.authorizer = auth; - } - - /** - * Creates a new {@code RangerPrivilegeHelper} from the supplied configuration. - * - *

    If the configuration does not specify a {@code HiveAuthorizerFactory}, a pass-through - * helper is returned that grants {@code READ_WRITE} on every object. If an exception occurs - * during initialization, a helper with a {@code null} authorizer is returned; every check - * will return {@link HMSPrivilegeHelper.AccessLevel#NONE}. - * - * @param conf the Hive configuration to read - * @return a new {@code RangerPrivilegeHelper} - */ - public static HMSPrivilegeHelper create(Configuration conf) { - HiveAuthorizer auth = null; - HiveConf hiveConf = (conf instanceof HiveConf) ? (HiveConf) conf : new HiveConf(conf, RangerPrivilegeHelper.class); - if (!hiveConf.getBoolVar(HiveConf.ConfVars.HIVE_AUTHORIZATION_ENABLED)) { - LOG.warn("RangerPrivilegeHelper: authorization is disabled ({}=false), all access granted.", - HiveConf.ConfVars.HIVE_AUTHORIZATION_ENABLED.varname); - return new HMSPrivilegeHelper() { - @Override - public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { - return AccessLevel.READ_WRITE; - } - @Override - public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { - return AccessLevel.READ_WRITE; - } - }; - } - try { - HiveAuthorizerFactory authorizerFactory = HiveUtils.getAuthorizerFactory(hiveConf, - HiveConf.ConfVars.HIVE_AUTHORIZATION_MANAGER); - if (authorizerFactory != null) { - LOG.debug("Using HiveAuthorizerFactory: {}", authorizerFactory.getClass().getName()); - - HiveAuthzSessionContext.Builder ctxBuilder = new HiveAuthzSessionContext.Builder(); - ctxBuilder.setClientType(HiveAuthzSessionContext.CLIENT_TYPE.OTHER); - ctxBuilder.setSessionString("IcebergRESTCatalog"); - HiveAuthzSessionContext sessionContext = ctxBuilder.build(); - - HiveAuthenticationProvider authenticator = HiveUtils.getAuthenticator( - hiveConf, HiveConf.ConfVars.HIVE_METASTORE_AUTHENTICATOR_MANAGER); - if (authenticator != null) { - authenticator.setConf(hiveConf); - } - - HiveMetastoreClientFactoryImpl clientFactory = new HiveMetastoreClientFactoryImpl(hiveConf); - auth = authorizerFactory.createHiveAuthorizer( - clientFactory, hiveConf, authenticator, sessionContext); - LOG.info("RangerPrivilegeHelper initialized with authorizer: {}", auth.getClass().getName()); - } else { - LOG.warn("RangerPrivilegeHelper: no authorizer factory found, all access granted. " + - "Check your Hive configuration for {}", - HiveConf.ConfVars.HIVE_AUTHORIZATION_MANAGER.varname); - return new HMSPrivilegeHelper() { - @Override - public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { - return AccessLevel.READ_WRITE; - } - @Override - public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { - return AccessLevel.READ_WRITE; - } - }; - } - } catch (Exception e) { - LOG.warn("RangerPrivilegeHelper: failed to initialize authorizer", e); - } - return new RangerPrivilegeHelper(auth); - } - - @Override - public boolean isAvailable() { - return authorizer != null; - } - - /** - * Returns the access level {@code userName} has on the table or view {@code dbName.tableName}. - */ - @Override - public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { - return queryPrivileges( - userName, - new HivePrivilegeObject(HivePrivilegeObjectType.TABLE_OR_VIEW, null, dbName, tableName), - TABLE_WRITE_PRIVILEGES); - } - - /** - * Returns the access level {@code userName} has on the namespace (database) {@code dbName}. - */ - @Override - public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { - return queryPrivileges( - userName, - new HivePrivilegeObject(HivePrivilegeObjectType.DATABASE, null, dbName, (String) null), - NAMESPACE_WRITE_PRIVILEGES); - } - - /** - * Core privilege evaluation: calls {@link HiveAuthorizer#showPrivileges} and maps the - * resulting list to an {@link AccessLevel} using the supplied {@code writePrivileges} set. - * - *

    Returns {@link AccessLevel#NONE} immediately if the authorizer is {@code null}. - * Any exception from the Ranger API is caught, logged, and treated as {@code NONE}. - * - * @param userName the short user name to evaluate - * @param privObj the object to check (table, view, or database) - * @param writePrivileges upper-cased privilege names (object-type-specific) that grant - * {@code READ_WRITE}; read-only access is determined by - * {@link #READ_PRIVILEGES} - * @return the resolved access level - */ - private AccessLevel queryPrivileges(String userName, HivePrivilegeObject privObj, - Set writePrivileges) { - if (authorizer == null) { - LOG.debug("No authorizer available, defaulting to NONE for {} user={}", privObj, userName); - return AccessLevel.NONE; - } - try { - HivePrincipal principal = new HivePrincipal(userName, HivePrincipal.HivePrincipalType.USER); - List privileges = authorizer.showPrivileges(principal, privObj); - if (privileges == null || privileges.isEmpty()) { - LOG.debug("No privileges found for user {} on {}", userName, privObj); - return AccessLevel.NONE; - } - boolean hasRead = false; - for (HivePrivilegeInfo info : privileges) { - String raw = info.getPrivilege().getName(); - // Ranger sometimes appends qualifiers: "SELECT(ACCESS_CONDITIONAL)" or "SELECT something". - int sep = raw.indexOf('('); - if (sep < 0) { - sep = raw.indexOf(' '); - } - String privName = (sep < 0 ? raw : raw.substring(0, sep)).trim().toUpperCase(); - LOG.debug("Privilege {} for user {} on {}", privName, userName, privObj); - if (writePrivileges.contains(privName)) { - return AccessLevel.READ_WRITE; - } - if (READ_PRIVILEGES.contains(privName)) { - hasRead = true; - } - } - return hasRead ? AccessLevel.READ_ONLY : AccessLevel.NONE; - } catch (Exception e) { - LOG.warn("Failed to check privileges for user {} on {}", userName, privObj, e); - return AccessLevel.NONE; - } - } -} diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 0aa90d6f45bc..c1fba9355300 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -18,13 +18,9 @@ package org.apache.iceberg.rest; -import static org.apache.iceberg.rest.HMSPrivilegeHelper.AccessLevel; - import java.io.Closeable; -import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.ref.SoftReference; -import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -32,8 +28,6 @@ import java.util.Locale; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -47,8 +41,6 @@ import com.github.benmanes.caffeine.cache.Ticker; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.conf.HiveConf; -import org.apache.hadoop.hive.metastore.RangerPrivilegeHelper; -import org.apache.hadoop.security.UserGroupInformation; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.MetadataTableType; @@ -61,7 +53,6 @@ import org.apache.iceberg.catalog.SupportsNamespaces; import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.catalog.ViewCatalog; -import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.exceptions.NamespaceNotEmptyException; import org.apache.iceberg.exceptions.NoSuchNamespaceException; import org.apache.iceberg.exceptions.NoSuchTableException; @@ -74,8 +65,7 @@ import org.slf4j.LoggerFactory; /** - * Caching wrapper around a {@link HiveCatalog} that adds two-level table caching and - * per-request authorization enforcement. + * Caching wrapper around a {@link HiveCatalog} that adds two-level table caching. * *

    Table caching (L2 + L1)

    *

    L2 — Caffeine cache. The primary table store. Each {@link Table} object is keyed by @@ -97,31 +87,6 @@ * which also evicts all derived {@link org.apache.iceberg.MetadataTableType metadata-table} * entries that share the base identifier.

    * - *

    Authorization

    - *

    Every table and view operation enforces an access-level check against the authenticated user - * (resolved via {@link org.apache.hadoop.security.UserGroupInformation#getCurrentUser()}). - * Authorization is performed by the configured {@link HMSPrivilegeHelper} - * (typically {@link org.apache.hadoop.hive.metastore.RangerPrivilegeHelper}). If no Ranger - * authorizer is configured the helper returns {@link HMSPrivilegeHelper.AccessLevel#NONE} for - * all requests, so access is denied rather than open by default.

    - * - *

    Access levels are cached in a single Caffeine cache (configurable via - * {@code hms.caching.catalog.access.cache.size}, default 256) that expires entries after the same - * TTL as the table cache. The cache is keyed by {@link TableIdentifier}: table and view operations - * use the identifier directly; namespace operations use a synthetic - * {@code TableIdentifier(namespace, "*")} key — {@code "*"} is not a valid Hive identifier - * character, so there is no collision with real table entries.

    - *
      - *
    • {@link HMSPrivilegeHelper.AccessLevel#READ_ONLY READ_ONLY} is required for - * {@code loadTable}/{@code loadView}/{@code listTables}/{@code listViews}.
    • - *
    • {@link HMSPrivilegeHelper.AccessLevel#READ_WRITE READ_WRITE} is required for - * {@code dropTable}/{@code dropView}/{@code renameTable}/{@code renameView}/ - * {@code registerTable}/{@code buildTable}/{@code buildView}.
    • - *
    - *

    Authorization entries are invalidated alongside their object — table-level on - * {@link #invalidateTable(TableIdentifier)}, namespace-level on - * {@link #dropNamespace(org.apache.iceberg.catalog.Namespace)}.

    - * *

    Observability

    *

    This class implements {@link HMSCachingCatalogMXBean} and registers itself with the platform * MBean server under the name {@code org.apache.iceberg.rest:type=HMSCachingCatalog,name=<catalogName>} @@ -166,10 +131,6 @@ public HiveCatalog getCatalog() { private final int l1Ttl; // The L1 cache size. private final int l1CacheSize; - // Computes privileges for a given table identifier and user. - private final HMSPrivilegeHelper privilegeHelper; - // Unified authz cache: keyed by TableIdentifier for tables/views, or by namespaceIdent(ns) for namespaces. - private final Cache> accessLevelCache; // Metrics counters. private final AtomicLong cacheHitCount = new AtomicLong(0); private final AtomicLong cacheMissCount = new AtomicLong(0); @@ -189,16 +150,6 @@ public HiveCatalog getCatalog() { * @param expirationMs the expiration time for the L2 cache, in milliseconds */ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { - this(catalog, expirationMs, RangerPrivilegeHelper.create(catalog.getConf())); - } - - /** - * Creates a new caching catalog that wraps the given HiveCatalog. - * @param catalog the underlying HiveCatalog - * @param expirationMs the expiration time for the L2 cache, in milliseconds - * @param privilegeHelper the helper to compute access levels for tables and namespaces - */ - HMSCachingCatalog(HiveCatalog catalog, long expirationMs, HMSPrivilegeHelper privilegeHelper) { this.hiveCatalog = catalog; this.metadataLocator = new MetadataLocator(catalog); this.tableCache = Caffeine.newBuilder() @@ -226,17 +177,6 @@ protected boolean removeEldestEntry(Map.Entry eldest) { l1Ttl = 0; l1CacheSize = 0; } - this.privilegeHelper = privilegeHelper; - // Covers both table/view and namespace entries; no need to be greater than the number of - // concurrent users × distinct objects, which is usually small (e.g., 256). - int accessLevelCacheSize = conf.getInt("hms.caching.catalog.access.cache.size", 256); - Caffeine accessCacheBuilder = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofMillis(expirationMs)) - .ticker(Ticker.systemTicker()); - if (accessLevelCacheSize > 0) { - accessCacheBuilder.maximumSize(accessLevelCacheSize); - } - this.accessLevelCache = accessCacheBuilder.build(); // Register this instance as a JMX MBean for monitoring. The catalog was initialized with the // metastore's CATALOG_DEFAULT name (see HMSCatalogFactory), so catalog.name() already yields // that value; using it directly keeps this class free of a Configuration/MetastoreConf @@ -244,104 +184,6 @@ protected boolean removeEldestEntry(Map.Entry eldest) { registerJmx(catalog.name()); } - private AccessLevel computeAccessLevel(TableIdentifier ident, String user) { - // Do not short-circuit on !isAvailable(): the pass-through helpers already return READ_WRITE - // when authorization is intentionally disabled, while a helper whose authorizer failed to - // initialize returns NONE (fail-closed). Overriding either here would open access. - try { - String dbName = ident.namespace().level(0); - String tableName = ident.name(); - return privilegeHelper.getAccessLevel(dbName, tableName, user); - } catch (Exception e) { - LOG.warn("Access level check failed for {}", ident, e); - return AccessLevel.NONE; - } - } - - /** - * Resolves the identifier used for authorization. A metadata table (e.g. {@code db.tbl.snapshots}) - * must be authorized against its base table ({@code db.tbl}); otherwise a user granted on an - * unrelated table that happens to share the metadata-type name (e.g. {@code db.snapshots}) could - * read the metadata table without access to the table it derives from. - */ - private TableIdentifier authzIdentifier(TableIdentifier identifier) { - Namespace ns = identifier.namespace(); - if (ns.levels().length >= 2 && MetadataTableType.from(identifier.name()) != null) { - // TableIdentifier.of(String...) treats the last level as the table name, so passing the - // metadata table's namespace levels ([db, tbl]) yields the base table identifier (db.tbl). - return TableIdentifier.of(ns.levels()); - } - return identifier; - } - - private String currentUser() { - try { - return UserGroupInformation.getCurrentUser().getShortUserName(); - } catch (IOException e) { - LOG.warn("Failed to determine current user", e); - return null; - } - } - - private AccessLevel cachedAccessLevel(TableIdentifier ident) { - String user = currentUser(); - if (user == null) { - return AccessLevel.NONE; - } - ConcurrentMap perUser = accessLevelCache.get(ident, k -> new ConcurrentHashMap<>()); - return perUser.computeIfAbsent(user, u -> computeAccessLevel(ident, u)); - } - - private void checkReadAccess(TableIdentifier ident) { - if (cachedAccessLevel(ident) == AccessLevel.NONE) { - throw new ForbiddenException("Access denied on %s", ident); - } - } - - private void checkWriteAccess(TableIdentifier ident) { - if (cachedAccessLevel(ident) != AccessLevel.READ_WRITE) { - throw new ForbiddenException("Write access denied on %s", ident); - } - } - - private AccessLevel computeNamespaceAccessLevel(Namespace namespace, String user) { - if (namespace.isEmpty()) { - return AccessLevel.NONE; - } - // See computeAccessLevel: never override the helper's decision based on availability. - try { - return privilegeHelper.getNamespaceAccessLevel(namespace.level(0), user); - } catch (Exception e) { - LOG.warn("Namespace access level check failed for {}", namespace, e); - return AccessLevel.NONE; - } - } - - private TableIdentifier namespaceIdent(Namespace ns) { - return TableIdentifier.of(ns, "*"); - } - - private AccessLevel cachedNamespaceAccessLevel(Namespace namespace) { - String user = currentUser(); - if (user == null) { - return AccessLevel.NONE; - } - ConcurrentMap perUser = accessLevelCache.get(namespaceIdent(namespace), k -> new ConcurrentHashMap<>()); - return perUser.computeIfAbsent(user, u -> computeNamespaceAccessLevel(namespace, u)); - } - - private void checkNamespaceReadAccess(Namespace namespace) { - if (cachedNamespaceAccessLevel(namespace) == AccessLevel.NONE) { - throw new ForbiddenException("Access denied on namespace %s", namespace); - } - } - - private void checkNamespaceWriteAccess(Namespace namespace) { - if (cachedNamespaceAccessLevel(namespace) != AccessLevel.READ_WRITE) { - throw new ForbiddenException("Write access denied on namespace %s", namespace); - } - } - /** * Registers this instance as a JMX MBean. * @@ -530,13 +372,11 @@ public String name() { @Override public List listTables(Namespace namespace) { - checkNamespaceReadAccess(namespace); return hiveCatalog.listTables(namespace); } @Override public boolean dropTable(TableIdentifier identifier, boolean purge) { - checkWriteAccess(identifier); boolean dropped = hiveCatalog.dropTable(identifier, purge); invalidateTable(identifier); return dropped; @@ -544,14 +384,12 @@ public boolean dropTable(TableIdentifier identifier, boolean purge) { @Override public void renameTable(TableIdentifier from, TableIdentifier to) { - checkWriteAccess(from); hiveCatalog.renameTable(from, to); invalidateTable(from); } @Override public Table registerTable(TableIdentifier identifier, String metadataFileLocation) { - checkWriteAccess(identifier); Table registered = hiveCatalog.registerTable(identifier, metadataFileLocation); invalidateTable(identifier); return registered; @@ -564,7 +402,6 @@ public void invalidateTable(TableIdentifier ident) { tableCache.invalidate(canonicalized); tableCache.invalidateAll(metadataTableIdentifiers(canonicalized)); l1Invalidate(canonicalized); - accessLevelCache.invalidate(canonicalized); } /** @@ -617,7 +454,6 @@ public void invalidateView(TableIdentifier identifier) { @Override public Table loadTable(final TableIdentifier identifier) { final TableIdentifier canonicalized = identifier; - checkReadAccess(authzIdentifier(canonicalized)); final Table cachedTable = tableCache.getIfPresent(canonicalized); long now = System.currentTimeMillis(); if (cachedTable != null) { @@ -707,13 +543,10 @@ public Map loadNamespaceMetadata(Namespace namespace) throws NoS @Override public boolean dropNamespace(Namespace namespace) throws NamespaceNotEmptyException { - // Use the underlying catalog directly to avoid the namespace read check for internal cache cleanup. for (TableIdentifier ident : hiveCatalog.listTables(namespace)) { invalidateTable(ident); } - boolean dropped = hiveCatalog.dropNamespace(namespace); - accessLevelCache.invalidate(namespaceIdent(namespace)); - return dropped; + return hiveCatalog.dropNamespace(namespace); } @Override @@ -733,19 +566,16 @@ public boolean namespaceExists(Namespace namespace) { @Override public Catalog.TableBuilder buildTable(TableIdentifier identifier, Schema schema) { - checkNamespaceWriteAccess(identifier.namespace()); return hiveCatalog.buildTable(identifier, schema); } @Override public List listViews(Namespace namespace) { - checkNamespaceReadAccess(namespace); return hiveCatalog.listViews(namespace); } @Override public View loadView(TableIdentifier identifier) { - checkReadAccess(identifier); return hiveCatalog.loadView(identifier); } @@ -756,19 +586,16 @@ public boolean viewExists(TableIdentifier identifier) { @Override public ViewBuilder buildView(TableIdentifier identifier) { - checkNamespaceWriteAccess(identifier.namespace()); return hiveCatalog.buildView(identifier); } @Override public boolean dropView(TableIdentifier identifier) { - checkWriteAccess(identifier); return hiveCatalog.dropView(identifier); } @Override public void renameView(TableIdentifier from, TableIdentifier to) { - checkWriteAccess(from); hiveCatalog.renameView(from, to); } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.java deleted file mode 100644 index 949a62431f4d..000000000000 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSPrivilegeHelper.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.iceberg.rest; - -/** - * Interface for a helper that determines the access level a user has on a table or namespace. - */ -public interface HMSPrivilegeHelper { - - /** - * The level of access a user has on a table. - */ - enum AccessLevel { - NONE, - READ_ONLY, - READ_WRITE - } - - /** - * Whether the helper was successfully initialized with an authorizer. - */ - default boolean isAvailable() { - return false; - } - - /** - * Determines the access level a user has on a given table by calling - * the Ranger showPrivileges API directly. - * - * @param dbName the database name - * @param tableName the table name - * @param userName the user name - * @return the access level (NONE, READ_ONLY, or READ_WRITE) - */ - AccessLevel getAccessLevel(String dbName, String tableName, String userName); - - /** - * Determines the access level a user has on a namespace (database). - * - * @param dbName the database name - * @param userName the user name - * @return the access level (NONE, READ_ONLY, or READ_WRITE) - */ - AccessLevel getNamespaceAccessLevel(String dbName, String userName); -} diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java deleted file mode 100644 index 4db71589674a..000000000000 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogAuthz.java +++ /dev/null @@ -1,497 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package org.apache.iceberg.rest; - -import java.security.PrivilegedExceptionAction; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - -import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; -import org.apache.hadoop.hive.metastore.annotation.MetastoreCheckinTest; -import org.apache.hadoop.hive.metastore.conf.MetastoreConf; -import org.apache.hadoop.security.UserGroupInformation; -import org.apache.iceberg.DataFile; -import org.apache.iceberg.DataFiles; -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.ForbiddenException; -import org.apache.iceberg.exceptions.NoSuchTableException; -import org.apache.iceberg.hive.HiveCatalog; -import org.apache.iceberg.rest.HMSPrivilegeHelper.AccessLevel; -import org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension; -import org.junit.experimental.categories.Category; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.junit.jupiter.api.extension.RegisterExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Tests that {@link HMSCachingCatalog} enforces access-level checks for every operation, and that - * the results are correctly cached and invalidated. - * - *

    A {@link StubPrivilegeHelper} controls exactly which access level each (user, db, table) or - * (user, db) triple receives, and counts how many times the helper was actually queried so that - * caching behaviour can be verified. - */ -@Category(MetastoreCheckinTest.class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class TestHMSCachingCatalogAuthz { - - private static final long CACHE_EXPIRY_MS = 5 * 60 * 1_000L; - private static final String NS = "authz_test_ns"; - private static final Namespace NAMESPACE = Namespace.of(NS); - private static final String TABLE = "authz_test_table"; - private static final TableIdentifier TABLE_ID = TableIdentifier.of(NAMESPACE, TABLE); - private static final Schema SCHEMA = new Schema(); - - @RegisterExtension - private static final HiveRESTCatalogServerExtension SERVER = - HiveRESTCatalogServerExtension.builder(AuthType.NONE) - .configure(MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), - String.valueOf(CACHE_EXPIRY_MS)) - .configure("hive.in.test", "true") - .build(); - - private HiveCatalog hiveCatalog; - private StubPrivilegeHelper stub; - private HMSCachingCatalog catalog; - - @BeforeAll - void setupAll() { - HMSCachingCatalog serverCatalog = HMSCachingCatalog.getLatestCache(null); - Assertions.assertNotNull(serverCatalog, "HMSCachingCatalog must be initialized by the server"); - hiveCatalog = serverCatalog.getCatalog(); - } - - @BeforeEach - void setupEach() { - stub = new StubPrivilegeHelper(); - catalog = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, stub); - hiveCatalog.createNamespace(NAMESPACE); - } - - @AfterEach - void cleanup() { - try { hiveCatalog.dropTable(TABLE_ID, false); } catch (Exception ignored) {} - try { hiveCatalog.dropNamespace(NAMESPACE); } catch (Exception ignored) {} - // Do not call catalog.close() — it would unregister the JMX MBean that other test - // classes (e.g. TestHMSCachingCatalogStats) rely on via the server-side catalog. - } - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - /** Runs {@code action} as the given short user name and returns its result. */ - private static T as(String user, PrivilegedExceptionAction action) throws Exception { - return UserGroupInformation.createRemoteUser(user).doAs(action); - } - - // --------------------------------------------------------------------------- - // Table-level access checks - // --------------------------------------------------------------------------- - - @Test - void testLoadTableReadOnlyGranted() throws Exception { - Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - // We must get back the very table we created, not merely a non-null result. - Table loaded = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(loaded.name()).isEqualTo(created.name()); - assertThat(loaded.location()).isEqualTo(created.location()); - } - - @Test - void testLoadTableReadWriteGranted() throws Exception { - Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_WRITE); - - Table loaded = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(loaded.name()).isEqualTo(created.name()); - assertThat(loaded.location()).isEqualTo(created.location()); - } - - @Test - void testLoadTableDenied() { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - // alice has no grant → NONE - - assertThatThrownBy(() -> as("alice", () -> { catalog.loadTable(TABLE_ID); return null; })) - .isInstanceOf(ForbiddenException.class); - } - - @Test - void testDropTableWriteGranted() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_WRITE); - - as("alice", () -> { catalog.dropTable(TABLE_ID); return null; }); - - // Table must be gone from HMS - assertThat(hiveCatalog.tableExists(TABLE_ID)).isFalse(); - } - - @Test - void testDropTableReadOnlyDenied() { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - assertThatThrownBy(() -> as("alice", () -> { catalog.dropTable(TABLE_ID); return null; })) - .isInstanceOf(ForbiddenException.class); - - // Table must still exist — the drop was vetoed - assertThat(hiveCatalog.tableExists(TABLE_ID)).isTrue(); - } - - @Test - void testDropTableDeniedWhenNoGrant() { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - - assertThatThrownBy(() -> as("alice", () -> { catalog.dropTable(TABLE_ID); return null; })) - .isInstanceOf(ForbiddenException.class); - } - - // --------------------------------------------------------------------------- - // Namespace-level access checks - // --------------------------------------------------------------------------- - - @Test - void testListTablesNamespaceReadGranted() throws Exception { - stub.grantNamespace("alice", NS, AccessLevel.READ_ONLY); - - // Must not throw; result may be empty - as("alice", () -> catalog.listTables(NAMESPACE)); - } - - @Test - void testListTablesNamespaceReadDenied() { - assertThatThrownBy(() -> as("alice", () -> catalog.listTables(NAMESPACE))) - .isInstanceOf(ForbiddenException.class); - } - - @Test - void testCreateTableNamespaceWriteGranted() throws Exception { - stub.grantNamespace("alice", NS, AccessLevel.READ_WRITE); - - // buildTable checks namespace write access; the actual create goes to the underlying HiveCatalog - TableIdentifier newTable = TableIdentifier.of(NAMESPACE, "new_table"); - Table created = as("alice", () -> catalog.buildTable(newTable, SCHEMA).create()); - - // Confirm the created table was actually persisted and is the one we built. We read it back - // through the underlying HiveCatalog (loadTable would require a separate table-level grant). - assertThat(hiveCatalog.tableExists(newTable)).isTrue(); - assertThat(hiveCatalog.loadTable(newTable).location()).isEqualTo(created.location()); - hiveCatalog.dropTable(newTable, false); - } - - @Test - void testCreateTableNamespaceReadOnlyDenied() { - stub.grantNamespace("alice", NS, AccessLevel.READ_ONLY); - - TableIdentifier newTable = TableIdentifier.of(NAMESPACE, "new_table"); - assertThatThrownBy(() -> as("alice", () -> catalog.buildTable(newTable, SCHEMA).create())) - .isInstanceOf(ForbiddenException.class); - - assertThat(hiveCatalog.tableExists(newTable)).isFalse(); - } - - @Test - void testCreateTableNamespaceNoGrantDenied() { - TableIdentifier newTable = TableIdentifier.of(NAMESPACE, "new_table"); - assertThatThrownBy(() -> as("alice", () -> catalog.buildTable(newTable, SCHEMA).create())) - .isInstanceOf(ForbiddenException.class); - } - - // --------------------------------------------------------------------------- - // Caching and invalidation - // --------------------------------------------------------------------------- - - @Test - void testAccessLevelIsCachedBetweenCalls() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - as("alice", () -> catalog.loadTable(TABLE_ID)); - int countAfterFirst = stub.getCallCount(); - - as("alice", () -> catalog.loadTable(TABLE_ID)); - int countAfterSecond = stub.getCallCount(); - - assertThat(countAfterFirst).isEqualTo(1); - // The access level was cached; the helper must not have been called again. - assertThat(countAfterSecond).isEqualTo(1); - } - - @Test - void testInvalidateTableClearsAuthzCache() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(stub.getCallCount()).isEqualTo(1); - - catalog.invalidateTable(TABLE_ID); - - as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(stub.getCallCount()).isEqualTo(2); - } - - @Test - void testNamespaceAccessLevelIsCachedBetweenCalls() throws Exception { - stub.grantNamespace("alice", NS, AccessLevel.READ_ONLY); - - as("alice", () -> catalog.listTables(NAMESPACE)); - int countAfterFirst = stub.getCallCount(); - - as("alice", () -> catalog.listTables(NAMESPACE)); - int countAfterSecond = stub.getCallCount(); - - assertThat(countAfterFirst).isEqualTo(1); - assertThat(countAfterSecond).isEqualTo(1); - } - - @Test - void testDifferentUsersGetIndependentAccessLevels() throws Exception { - Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - // bob has no grant - - Table loaded = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(loaded.location()).isEqualTo(created.location()); - assertThatThrownBy(() -> as("bob", () -> { catalog.loadTable(TABLE_ID); return null; })) - .isInstanceOf(ForbiddenException.class); - } - - // --------------------------------------------------------------------------- - // Metadata cache behavior - // --------------------------------------------------------------------------- - - @Test - void testL2CacheReturnsSameTableInstance() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - Table first = as("alice", () -> catalog.loadTable(TABLE_ID)); - Table second = as("alice", () -> catalog.loadTable(TABLE_ID)); - - // Caffeine stores object references; a cache hit returns the identical instance. - assertThat(first).isSameAs(second); - } - - @Test - void testInvalidateTableForcesReload() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - Table before = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(before.currentSnapshot()).isNull(); // fresh table, no snapshot yet - - // Advance the underlying table's metadata location by committing a new snapshot. - Table raw = hiveCatalog.loadTable(TABLE_ID); - DataFile file = DataFiles.builder(PartitionSpec.unpartitioned()) - .withPath(raw.location() + "/data/file-0.parquet") - .withFileSizeInBytes(1024).withRecordCount(1).build(); - raw.newAppend().appendFile(file).commit(); - - // Explicit invalidation evicts L2, L1, and authz caches. - catalog.invalidateTable(TABLE_ID); - - Table after = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(after).isNotSameAs(before); - assertThat(after.currentSnapshot()).isNotNull(); - assertThat(after.currentSnapshot().snapshotId()).isEqualTo(raw.currentSnapshot().snapshotId()); - } - - @Test - void testDropTableEvictsCache() throws Exception { - // Create a table with a snapshot so the cached version has observable state. - hiveCatalog.createTable(TABLE_ID, SCHEMA); - Table raw = hiveCatalog.loadTable(TABLE_ID); - DataFile file = DataFiles.builder(PartitionSpec.unpartitioned()) - .withPath(raw.location() + "/data/file-0.parquet") - .withFileSizeInBytes(1024).withRecordCount(1).build(); - raw.newAppend().appendFile(file).commit(); - - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_WRITE); - - Table cached = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(cached.currentSnapshot()).isNotNull(); - - // Drop clears L2, L1, and authz caches via invalidateTable. - as("alice", () -> { catalog.dropTable(TABLE_ID); return null; }); - - // Recreate a fresh empty table (no snapshot) and reload. - hiveCatalog.createTable(TABLE_ID, SCHEMA); - - Table reloaded = as("alice", () -> catalog.loadTable(TABLE_ID)); - assertThat(reloaded).isNotSameAs(cached); - assertThat(reloaded.currentSnapshot()).isNull(); - } - - // --------------------------------------------------------------------------- - // Fail-closed and metadata-table authorization - // --------------------------------------------------------------------------- - - @Test - void testUnavailableHelperReturningNoneDenies() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - // A helper whose authorizer failed to initialize: not available, but fail-closed (NONE). - // The catalog must honour that NONE and not fall back to READ_WRITE. - HMSPrivilegeHelper failClosed = new HMSPrivilegeHelper() { - @Override public boolean isAvailable() { return false; } - @Override public AccessLevel getAccessLevel(String db, String table, String user) { - return AccessLevel.NONE; - } - @Override public AccessLevel getNamespaceAccessLevel(String db, String user) { - return AccessLevel.NONE; - } - }; - HMSCachingCatalog failClosedCatalog = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, failClosed); - - assertThatThrownBy(() -> as("alice", () -> { failClosedCatalog.loadTable(TABLE_ID); return null; })) - .isInstanceOf(ForbiddenException.class); - } - - @Test - void testMetadataTableAuthorizedAgainstBaseTable() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - TableIdentifier metaId = TableIdentifier.of(Namespace.of(NS, TABLE), "snapshots"); - - // A grant on a decoy table that merely shares the metadata-type name must NOT leak access - // to the metadata table, which derives from TABLE_ID. - stub.grantTable("alice", NS, "snapshots", AccessLevel.READ_ONLY); - assertThatThrownBy(() -> as("alice", () -> { catalog.loadTable(metaId); return null; })) - .isInstanceOf(ForbiddenException.class); - - // A grant on the base table authorizes its metadata tables (distinct user to avoid the - // cached NONE from the denial above). - stub.grantTable("bob", NS, TABLE, AccessLevel.READ_ONLY); - Table snapshots = as("bob", () -> catalog.loadTable(metaId)); - assertThat(snapshots).isNotNull(); - } - - @Test - void testLoadTableWithL1CacheDisabled() throws Exception { - Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - // Disable the L1 recency guard; its backing map is then an immutable empty map, so any write - // to it would throw. The second load exercises the L2-hit path that records L1 freshness. - var conf = hiveCatalog.getConf(); - int prevSize = conf.getInt("hms.caching.catalog.l1.cache.size", 32); - int prevTtl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); - conf.setInt("hms.caching.catalog.l1.cache.size", 0); - try { - HMSCachingCatalog noL1 = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, stub); - Table first = as("alice", () -> noL1.loadTable(TABLE_ID)); - Table second = as("alice", () -> noL1.loadTable(TABLE_ID)); - assertThat(first.location()).isEqualTo(created.location()); - assertThat(second.location()).isEqualTo(created.location()); - } finally { - conf.setInt("hms.caching.catalog.l1.cache.size", prevSize); - conf.setInt("hms.caching.catalog.l1.cache.ttl", prevTtl); - } - } - - @Test - void testReloadOfDroppedTableThrowsNoSuchTable() throws Exception { - hiveCatalog.createTable(TABLE_ID, SCHEMA); - stub.grantTable("alice", NS, TABLE, AccessLevel.READ_ONLY); - - // Disable the L1 recency guard so the second load re-checks the HMS location instead of - // short-circuiting on L1 freshness. - var conf = hiveCatalog.getConf(); - int prevSize = conf.getInt("hms.caching.catalog.l1.cache.size", 32); - conf.setInt("hms.caching.catalog.l1.cache.size", 0); - try { - HMSCachingCatalog noL1 = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, stub); - - // Warm the L2 cache with the table. - Table loaded = as("alice", () -> noL1.loadTable(TABLE_ID)); - assertThat(loaded).isNotNull(); - - // Drop the table straight through the underlying catalog so noL1's L2 entry is left stale. - hiveCatalog.dropTable(TABLE_ID, false); - - // Reloading must not serve the ghost: the null HMS location evicts the entry and signals - // not-found (the cached alice/READ_ONLY authz decision still passes, so we reach the cache path). - assertThatThrownBy(() -> as("alice", () -> { noL1.loadTable(TABLE_ID); return null; })) - .isInstanceOf(NoSuchTableException.class); - } finally { - conf.setInt("hms.caching.catalog.l1.cache.size", prevSize); - } - } - - // --------------------------------------------------------------------------- - // Stub privilege helper - // --------------------------------------------------------------------------- - - /** - * Configurable stub for {@link HMSPrivilegeHelper} that records how many times it was queried. - * Grants are registered with {@link #grantTable} / {@link #grantNamespace}; any unregistered - * combination returns {@link AccessLevel#NONE}. - */ - static class StubPrivilegeHelper implements HMSPrivilegeHelper { - - private final Map tableGrants = new ConcurrentHashMap<>(); - private final Map namespaceGrants = new ConcurrentHashMap<>(); - private final AtomicInteger callCount = new AtomicInteger(); - - void grantTable(String user, String db, String table, AccessLevel level) { - tableGrants.put(user + "/" + db + "." + table, level); - } - - void grantNamespace(String user, String db, AccessLevel level) { - namespaceGrants.put(user + "/" + db, level); - } - - int getCallCount() { - return callCount.get(); - } - - @Override - public boolean isAvailable() { - return true; - } - - @Override - public AccessLevel getAccessLevel(String dbName, String tableName, String userName) { - callCount.incrementAndGet(); - return tableGrants.getOrDefault(userName + "/" + dbName + "." + tableName, AccessLevel.NONE); - } - - @Override - public AccessLevel getNamespaceAccessLevel(String dbName, String userName) { - callCount.incrementAndGet(); - return namespaceGrants.getOrDefault(userName + "/" + dbName, AccessLevel.NONE); - } - } -} diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java new file mode 100644 index 000000000000..47df305ce3bd --- /dev/null +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.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.iceberg.rest; + +import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; +import org.apache.hadoop.hive.metastore.annotation.MetastoreCheckinTest; +import org.apache.hadoop.hive.metastore.conf.MetastoreConf; +import org.apache.iceberg.DataFile; +import org.apache.iceberg.DataFiles; +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.NoSuchTableException; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.rest.extension.HiveRESTCatalogServerExtension; +import org.junit.experimental.categories.Category; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests the two-level table caching behaviour of {@link HMSCachingCatalog}: L2 (Caffeine) hits + * return the same instance, explicit invalidation and drop evict the cache, the L1 recency guard + * can be disabled, and a table dropped underneath the cache reports not-found rather than serving + * a stale instance. + */ +@Category(MetastoreCheckinTest.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TestHMSCachingCatalogCache { + + private static final long CACHE_EXPIRY_MS = 5 * 60 * 1_000L; + private static final String NS = "cache_test_ns"; + private static final Namespace NAMESPACE = Namespace.of(NS); + private static final String TABLE = "cache_test_table"; + private static final TableIdentifier TABLE_ID = TableIdentifier.of(NAMESPACE, TABLE); + private static final Schema SCHEMA = new Schema(); + + @RegisterExtension + private static final HiveRESTCatalogServerExtension SERVER = + HiveRESTCatalogServerExtension.builder(AuthType.NONE) + .configure(MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), + String.valueOf(CACHE_EXPIRY_MS)) + .configure("hive.in.test", "true") + .build(); + + private HiveCatalog hiveCatalog; + private HMSCachingCatalog catalog; + + @BeforeAll + void setupAll() { + HMSCachingCatalog serverCatalog = HMSCachingCatalog.getLatestCache(null); + Assertions.assertNotNull(serverCatalog, "HMSCachingCatalog must be initialized by the server"); + hiveCatalog = serverCatalog.getCatalog(); + } + + @BeforeEach + void setupEach() { + catalog = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS); + hiveCatalog.createNamespace(NAMESPACE); + } + + @AfterEach + void cleanup() { + try { hiveCatalog.dropTable(TABLE_ID, false); } catch (Exception ignored) {} + try { hiveCatalog.dropNamespace(NAMESPACE); } catch (Exception ignored) {} + // Do not call catalog.close() — it would unregister the JMX MBean that other test + // classes (e.g. TestHMSCachingCatalogStats) rely on via the server-side catalog. + } + + @Test + void testL2CacheReturnsSameTableInstance() { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + + Table first = catalog.loadTable(TABLE_ID); + Table second = catalog.loadTable(TABLE_ID); + + // Caffeine stores object references; a cache hit returns the identical instance. + assertThat(first).isSameAs(second); + } + + @Test + void testInvalidateTableForcesReload() { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + + Table before = catalog.loadTable(TABLE_ID); + assertThat(before.currentSnapshot()).isNull(); // fresh table, no snapshot yet + + // Advance the underlying table's metadata location by committing a new snapshot. + Table raw = hiveCatalog.loadTable(TABLE_ID); + DataFile file = DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(raw.location() + "/data/file-0.parquet") + .withFileSizeInBytes(1024).withRecordCount(1).build(); + raw.newAppend().appendFile(file).commit(); + + // Explicit invalidation evicts both the L2 and L1 caches. + catalog.invalidateTable(TABLE_ID); + + Table after = catalog.loadTable(TABLE_ID); + assertThat(after).isNotSameAs(before); + assertThat(after.currentSnapshot()).isNotNull(); + assertThat(after.currentSnapshot().snapshotId()).isEqualTo(raw.currentSnapshot().snapshotId()); + } + + @Test + void testDropTableEvictsCache() { + // Create a table with a snapshot so the cached version has observable state. + hiveCatalog.createTable(TABLE_ID, SCHEMA); + Table raw = hiveCatalog.loadTable(TABLE_ID); + DataFile file = DataFiles.builder(PartitionSpec.unpartitioned()) + .withPath(raw.location() + "/data/file-0.parquet") + .withFileSizeInBytes(1024).withRecordCount(1).build(); + raw.newAppend().appendFile(file).commit(); + + Table cached = catalog.loadTable(TABLE_ID); + assertThat(cached.currentSnapshot()).isNotNull(); + + // Drop clears both the L2 and L1 caches via invalidateTable. + catalog.dropTable(TABLE_ID); + + // Recreate a fresh empty table (no snapshot) and reload. + hiveCatalog.createTable(TABLE_ID, SCHEMA); + + Table reloaded = catalog.loadTable(TABLE_ID); + assertThat(reloaded).isNotSameAs(cached); + assertThat(reloaded.currentSnapshot()).isNull(); + } + + @Test + void testLoadTableWithL1CacheDisabled() { + Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); + + // Disable the L1 recency guard; its backing map is then an immutable empty map, so any write + // to it would throw. The second load exercises the L2-hit path that records L1 freshness. + var conf = hiveCatalog.getConf(); + int prevSize = conf.getInt("hms.caching.catalog.l1.cache.size", 32); + int prevTtl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); + conf.setInt("hms.caching.catalog.l1.cache.size", 0); + try { + HMSCachingCatalog noL1 = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS); + Table first = noL1.loadTable(TABLE_ID); + Table second = noL1.loadTable(TABLE_ID); + assertThat(first.location()).isEqualTo(created.location()); + assertThat(second.location()).isEqualTo(created.location()); + } finally { + conf.setInt("hms.caching.catalog.l1.cache.size", prevSize); + conf.setInt("hms.caching.catalog.l1.cache.ttl", prevTtl); + } + } + + @Test + void testReloadOfDroppedTableThrowsNoSuchTable() { + hiveCatalog.createTable(TABLE_ID, SCHEMA); + + // Disable the L1 recency guard so the second load re-checks the HMS location instead of + // short-circuiting on L1 freshness. + var conf = hiveCatalog.getConf(); + int prevSize = conf.getInt("hms.caching.catalog.l1.cache.size", 32); + conf.setInt("hms.caching.catalog.l1.cache.size", 0); + try { + HMSCachingCatalog noL1 = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS); + + // Warm the L2 cache with the table. + Table loaded = noL1.loadTable(TABLE_ID); + assertThat(loaded).isNotNull(); + + // Drop the table straight through the underlying catalog so noL1's L2 entry is left stale. + hiveCatalog.dropTable(TABLE_ID, false); + + // Reloading must not serve the ghost: the null HMS location evicts the entry and signals + // not-found. + assertThatThrownBy(() -> noL1.loadTable(TABLE_ID)) + .isInstanceOf(NoSuchTableException.class); + } finally { + conf.setInt("hms.caching.catalog.l1.cache.size", prevSize); + } + } +} From 16ad2cbb9bbb133da6a0b5f59b0a7a37cee1e7f7 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 14:56:27 +0200 Subject: [PATCH 11/20] HIVE-29035: fix javadoc heading sequence (h3 -> h2) in HMSCachingCatalog --- .../main/java/org/apache/iceberg/rest/HMSCachingCatalog.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index c1fba9355300..049bab65589d 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -67,7 +67,7 @@ /** * Caching wrapper around a {@link HiveCatalog} that adds two-level table caching. * - *

    Table caching (L2 + L1)

    + *

    Table caching (L2 + L1)

    *

    L2 — Caffeine cache. The primary table store. Each {@link Table} object is keyed by * its {@link TableIdentifier} and expires after the configured inactivity period * ({@code ICEBERG_CATALOG_CACHE_EXPIRY}, in milliseconds). On a cache miss, the table is loaded @@ -87,7 +87,7 @@ * which also evicts all derived {@link org.apache.iceberg.MetadataTableType metadata-table} * entries that share the base identifier.

    * - *

    Observability

    + *

    Observability

    *

    This class implements {@link HMSCachingCatalogMXBean} and registers itself with the platform * MBean server under the name {@code org.apache.iceberg.rest:type=HMSCachingCatalog,name=<catalogName>} * so that cache hit/miss counts and invalidation counts can be monitored via JMX. The From ad771f32401a6b5a8805567a0917b4cdf50c3da5 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 15:48:33 +0200 Subject: [PATCH 12/20] HIVE-29035: address Copilot review Make the L1 recency guard access-ordered (LRU) so re-confirming a hot table moves it to the tail and the eldest evicted is the least-recently-used entry, not the least-recently-inserted one. Fix the MetadataLocator.getLocation javadoc, which claimed it returns null for non-metadata tables when it also serves base-table identifiers. --- .../java/org/apache/iceberg/hive/MetadataLocator.java | 11 ++++++----- .../org/apache/iceberg/rest/HMSCachingCatalog.java | 11 +++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index 19e1ce966993..4e644a851325 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -54,12 +54,13 @@ public HiveCatalog getCatalog() { } /** - * Returns the location of the metadata table identified by the given identifier, or null if the table is - * not a metadata table. + * Returns the current metadata-file location of the table identified by the given identifier. The + * identifier may be either a base table (e.g. {@code db.table}) or one of its metadata tables + * (e.g. {@code db.table.snapshots}), which is resolved to its base table before the lookup. *

    This uses the Thrift API to fetch the table parameters, which is more efficient than fetching the entire table object.

    - * @param identifier the identifier of the metadata table to fetch the location for - * @return the location of the metadata table, or null if the table (or its database/catalog) does - * not exist, or the identifier is not a valid (metadata) table identifier + * @param identifier the base-table or metadata-table identifier to fetch the location for + * @return the current metadata-file location, or null if the table (or its database/catalog) does + * not exist, or the identifier is neither a valid table nor a valid metadata-table identifier * @throws RuntimeException if the HMS lookup fails for any reason other than the object not existing */ public String getLocation(TableIdentifier identifier) { diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 049bab65589d..dece8648c0df 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -74,10 +74,10 @@ * from the underlying {@link HiveCatalog} and its current metadata location is recorded. * Subsequent hits skip the HMS round-trip entirely.

    * - *

    L1 — LinkedHashMap recency guard. A small bounded map (default 32 entries, 3 s TTL; - * configurable via {@code hms.caching.catalog.l1.cache.size} and + *

    L1 — LinkedHashMap recency guard. A small bounded, access-ordered (LRU) map (default + * 32 entries, 3 s TTL; configurable via {@code hms.caching.catalog.l1.cache.size} and * {@code hms.caching.catalog.l1.cache.ttl}) that tracks when each L2-cached table was last - * confirmed fresh. While the L1 entry is live, {@code loadTable} skips the metadata-location + * confirmed fresh; when it overflows, the least-recently-used entry is evicted. While the L1 entry is live, {@code loadTable} skips the metadata-location * staleness check against HMS. Once the L1 entry expires, the next call re-validates the stored * metadata location; if it has changed, the L2 entry is evicted ({@code onCacheInvalidate}) and * a fresh load is performed. The L1 layer trades a small risk of serving a stale snapshot for a @@ -164,7 +164,10 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { int l1size = conf.getInt("hms.caching.catalog.l1.cache.size", 32); int l1ttl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); if (l1size > 0 && l1ttl > 0) { - l1Cache = Collections.synchronizedMap(new LinkedHashMap() { + // Access-ordered (LRU) so that re-confirming a hot table via l1MarkFresh (a put on an + // existing key) moves it to the tail; the eldest evicted by removeEldestEntry is then the + // least-recently-used entry rather than the least-recently-inserted one. + l1Cache = Collections.synchronizedMap(new LinkedHashMap(l1size, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > l1CacheSize; From 44afaa04d58a483d88f18cd726b575e622fc7ef6 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 16:25:12 +0200 Subject: [PATCH 13/20] HIVE-29035: use validateIcebergViewNotLoadedAsIcebergTable in MetadataLocator Narrows the metadata-location lookup validation to only reject an Iceberg view loaded as a table (throwing NoSuchTableException), matching loadTable semantics, instead of rejecting any non-Iceberg-table object. --- .../src/main/java/org/apache/iceberg/hive/MetadataLocator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index 4e644a851325..ca91cfee3dd7 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -84,7 +84,7 @@ public String getLocation(TableIdentifier identifier) { if (tables != null && !tables.isEmpty()) { Table table = tables.getFirst(); if (table != null) { - HiveOperationsBase.validateTableIsIceberg(table, tableName); + HiveOperationsBase.validateIcebergViewNotLoadedAsIcebergTable(table, baseTableIdentifier.toString()); return table.getParameters().get(BaseMetastoreTableOperations.METADATA_LOCATION_PROP); } } From 6854829c7832cb61d453726c91f6cb46df20f956 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 16:28:37 +0200 Subject: [PATCH 14/20] HIVE-29035: use LongAdder for cache statistics counters LongAdder scales better than AtomicLong under concurrent increments on the cache callback path. The debug log now reads the running total via sum(), guarded by isDebugEnabled() so the write path stays contention-free. --- .../iceberg/rest/HMSCachingCatalog.java | 94 +++++++++++-------- 1 file changed, 54 insertions(+), 40 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index dece8648c0df..f04cf92a7f36 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -29,7 +29,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; import java.util.function.Function; import javax.management.JMException; @@ -132,14 +132,14 @@ public HiveCatalog getCatalog() { // The L1 cache size. private final int l1CacheSize; // Metrics counters. - private final AtomicLong cacheHitCount = new AtomicLong(0); - private final AtomicLong cacheMissCount = new AtomicLong(0); - private final AtomicLong cacheLoadCount = new AtomicLong(0); - private final AtomicLong cacheInvalidateCount = new AtomicLong(0); - private final AtomicLong cacheMetaLoadCount = new AtomicLong(0); + private final LongAdder cacheHitCount = new LongAdder(); + private final LongAdder cacheMissCount = new LongAdder(); + private final LongAdder cacheLoadCount = new LongAdder(); + private final LongAdder cacheInvalidateCount = new LongAdder(); + private final LongAdder cacheMetaLoadCount = new LongAdder(); // L1 cache metrics: counted only when the L2 (Caffeine) cache already has the entry. - private final AtomicLong l1CacheHitCount = new AtomicLong(0); - private final AtomicLong l1CacheMissCount = new AtomicLong(0); + private final LongAdder l1CacheHitCount = new LongAdder(); + private final LongAdder l1CacheMissCount = new LongAdder(); // JMX ObjectName under which this instance is registered (may be null if registration failed). private ObjectName jmxObjectName; @@ -216,8 +216,10 @@ private void registerJmx(String catalogName) { * @param tid the table identifier to invalidate */ private void onCacheInvalidate(TableIdentifier tid) { - long count = cacheInvalidateCount.incrementAndGet(); - LOG.debug("Cache invalidate {}: {}", tid, count); + cacheInvalidateCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("Cache invalidate {}: {}", tid, cacheInvalidateCount.sum()); + } } /** @@ -226,8 +228,10 @@ private void onCacheInvalidate(TableIdentifier tid) { * @param tid the table identifier */ private void onCacheLoad(TableIdentifier tid) { - long count = cacheLoadCount.incrementAndGet(); - LOG.debug("Cache load {}: {}", tid, count); + cacheLoadCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("Cache load {}: {}", tid, cacheLoadCount.sum()); + } } /** @@ -236,8 +240,10 @@ private void onCacheLoad(TableIdentifier tid) { * @param tid the table identifier */ private void onCacheHit(TableIdentifier tid) { - long count = cacheHitCount.incrementAndGet(); - LOG.debug("Cache hit {} : {}", tid, count); + cacheHitCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("Cache hit {} : {}", tid, cacheHitCount.sum()); + } } /** @@ -246,8 +252,10 @@ private void onCacheHit(TableIdentifier tid) { * @param tid the table identifier */ private void onCacheMiss(TableIdentifier tid) { - long count = cacheMissCount.incrementAndGet(); - LOG.debug("Cache miss {}: {}", tid, count); + cacheMissCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("Cache miss {}: {}", tid, cacheMissCount.sum()); + } } /** @@ -256,8 +264,10 @@ private void onCacheMiss(TableIdentifier tid) { * @param tid the table identifier */ private void onCacheMetaLoad(TableIdentifier tid) { - long count = cacheMetaLoadCount.incrementAndGet(); - LOG.debug("Cache meta-load {}: {}", tid, count); + cacheMetaLoadCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("Cache meta-load {}: {}", tid, cacheMetaLoadCount.sum()); + } } /** @@ -267,8 +277,10 @@ private void onCacheMetaLoad(TableIdentifier tid) { * @param tid the table identifier */ private void onL1CacheHit(TableIdentifier tid) { - long count = l1CacheHitCount.incrementAndGet(); - LOG.debug("L1 cache hit {}: {}", tid, count); + l1CacheHitCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("L1 cache hit {}: {}", tid, l1CacheHitCount.sum()); + } } /** @@ -278,69 +290,71 @@ private void onL1CacheHit(TableIdentifier tid) { * @param tid the table identifier */ private void onL1CacheMiss(TableIdentifier tid) { - long count = l1CacheMissCount.incrementAndGet(); - LOG.debug("L1 cache miss {}: {}", tid, count); + l1CacheMissCount.increment(); + if (LOG.isDebugEnabled()) { + LOG.debug("L1 cache miss {}: {}", tid, l1CacheMissCount.sum()); + } } // Getter methods for accessing metrics @Override public long getCacheHitCount() { - return cacheHitCount.get(); + return cacheHitCount.sum(); } @Override public long getCacheMissCount() { - return cacheMissCount.get(); + return cacheMissCount.sum(); } @Override public long getCacheLoadCount() { - return cacheLoadCount.get(); + return cacheLoadCount.sum(); } @Override public long getCacheInvalidateCount() { - return cacheInvalidateCount.get(); + return cacheInvalidateCount.sum(); } @Override public long getCacheMetaLoadCount() { - return cacheMetaLoadCount.get(); + return cacheMetaLoadCount.sum(); } @Override public double getCacheHitRate() { - long hits = cacheHitCount.get(); - long total = hits + cacheMissCount.get(); + long hits = cacheHitCount.sum(); + long total = hits + cacheMissCount.sum(); return total == 0 ? 0.0 : (double) hits / total; } @Override public long getL1CacheHitCount() { - return l1CacheHitCount.get(); + return l1CacheHitCount.sum(); } @Override public long getL1CacheMissCount() { - return l1CacheMissCount.get(); + return l1CacheMissCount.sum(); } @Override public double getL1CacheHitRate() { - long hits = l1CacheHitCount.get(); - long total = hits + l1CacheMissCount.get(); + long hits = l1CacheHitCount.sum(); + long total = hits + l1CacheMissCount.sum(); return total == 0 ? 0.0 : (double) hits / total; } @Override public void resetCacheStats() { - cacheHitCount.set(0); - cacheMissCount.set(0); - cacheLoadCount.set(0); - cacheInvalidateCount.set(0); - cacheMetaLoadCount.set(0); - l1CacheHitCount.set(0); - l1CacheMissCount.set(0); + cacheHitCount.reset(); + cacheMissCount.reset(); + cacheLoadCount.reset(); + cacheInvalidateCount.reset(); + cacheMetaLoadCount.reset(); + l1CacheHitCount.reset(); + l1CacheMissCount.reset(); LOG.debug("Cache stats reset"); } From ab7f86470daa0f364fb4dc6dde42c5ba53c5f22a Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 17:04:40 +0200 Subject: [PATCH 15/20] HIVE-29035: remove test-only getLatestCache escape hatch from HMSCachingCatalog Extract HMSCatalogFactory.createHiveCatalog so tests build catalogs through the production path, and have the server extension expose newServerCatalog / newCachingCatalog keyed off the metastore's real Thrift URI. Drop the static cacheRef SoftReference, getLatestCache, and the HIVE_IN_TEST hook from HMSCachingCatalog. Rework the caching cache/stats tests to drive their own catalog instance and assert counters via getters and JMX. --- .../iceberg/rest/HMSCachingCatalog.java | 85 +++---- .../iceberg/rest/HMSCatalogFactory.java | 17 +- .../rest/TestHMSCachingCatalogCache.java | 4 +- .../rest/TestHMSCachingCatalogStats.java | 213 +++++++----------- .../HiveRESTCatalogServerExtension.java | 29 +++ .../rest/extension/RESTCatalogServer.java | 5 + 6 files changed, 167 insertions(+), 186 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index f04cf92a7f36..b12f6dee5183 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -20,7 +20,6 @@ import java.io.Closeable; import java.lang.management.ManagementFactory; -import java.lang.ref.SoftReference; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; @@ -30,7 +29,6 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.LongAdder; -import java.util.function.Function; import javax.management.JMException; import javax.management.MBeanServer; @@ -40,7 +38,6 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Ticker; import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.hive.conf.HiveConf; import org.apache.iceberg.BaseMetadataTable; import org.apache.iceberg.HasTableOperations; import org.apache.iceberg.MetadataTableType; @@ -98,19 +95,6 @@ public final class HMSCachingCatalog implements Catalog, SupportsNamespaces, ViewCatalog, HMSCachingCatalogMXBean, Closeable { private static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); - @TestOnly - private static SoftReference cacheRef = new SoftReference<>(null); - - @TestOnly - @SuppressWarnings("unchecked") - public static C getLatestCache(Function extractor) { - HMSCachingCatalog cache = cacheRef.get(); - if (cache == null) { - return null; - } - return extractor == null ? (C) cache : extractor.apply(cache); - } - @TestOnly public HiveCatalog getCatalog() { return hiveCatalog; @@ -143,7 +127,6 @@ public HiveCatalog getCatalog() { // JMX ObjectName under which this instance is registered (may be null if registration failed). private ObjectName jmxObjectName; - /** * Creates a new caching catalog that wraps the given HiveCatalog. * @param catalog the underlying HiveCatalog @@ -157,10 +140,6 @@ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { .ticker(Ticker.systemTicker()) .build(); Configuration conf = catalog.getConf(); - if (HiveConf.getBoolVar(conf, HiveConf.ConfVars.HIVE_IN_TEST)) { - // Only keep a reference to the latest cache for testing purpose, so that tests can manipulate the catalog. - cacheRef = new SoftReference<>(this); - } int l1size = conf.getInt("hms.caching.catalog.l1.cache.size", 32); int l1ttl = conf.getInt("hms.caching.catalog.l1.cache.ttl", 3_000); if (l1size > 0 && l1ttl > 0) { @@ -415,10 +394,9 @@ public Table registerTable(TableIdentifier identifier, String metadataFileLocati @Override public void invalidateTable(TableIdentifier ident) { hiveCatalog.invalidateTable(ident); - TableIdentifier canonicalized = ident; - tableCache.invalidate(canonicalized); - tableCache.invalidateAll(metadataTableIdentifiers(canonicalized)); - l1Invalidate(canonicalized); + tableCache.invalidate(ident); + tableCache.invalidateAll(metadataTableIdentifiers(ident)); + l1Invalidate(ident); } /** @@ -470,77 +448,76 @@ public void invalidateView(TableIdentifier identifier) { @Override public Table loadTable(final TableIdentifier identifier) { - final TableIdentifier canonicalized = identifier; - final Table cachedTable = tableCache.getIfPresent(canonicalized); + final Table cachedTable = tableCache.getIfPresent(identifier); long now = System.currentTimeMillis(); if (cachedTable != null) { // Determine if L1 cache is valid based on the last cached time and the TTL. // If the table is in L1 cache, we can skip the location check and return the cached table directly, // which can significantly reduce the latency for repeated access to the same table. - Long lastCached = l1Cache.get(canonicalized); + Long lastCached = l1Cache.get(identifier); if (lastCached != null) { if (now - lastCached < l1Ttl) { - LOG.debug("Table {} is in L1 cache, returning cached table", canonicalized); - onL1CacheHit(canonicalized); - onCacheHit(canonicalized); + LOG.debug("Table {} is in L1 cache, returning cached table", identifier); + onL1CacheHit(identifier); + onCacheHit(identifier); return cachedTable; } else { - l1Invalidate(canonicalized); - onL1CacheMiss(canonicalized); + l1Invalidate(identifier); + onL1CacheMiss(identifier); } } else { - onL1CacheMiss(canonicalized); + onL1CacheMiss(identifier); } // If the table is no longer in L1 cache, we need to check the location. - final String location = metadataLocator.getLocation(canonicalized); + final String location = metadataLocator.getLocation(identifier); if (location == null) { // A null location means the table no longer exists in HMS. The cached instance is stale and // its metadata/manifests are highly likely to be deleted, so we must not serve it: evict the // entry and signal not-found rather than returning a ghost table. - LOG.debug("Table {} no longer exists in HMS, evicting stale cache entry", canonicalized); - invalidateTable(canonicalized); - throw new NoSuchTableException("Table does not exist: %s", canonicalized); + LOG.debug("Table {} no longer exists in HMS, evicting stale cache entry", identifier); + invalidateTable(identifier); + throw new NoSuchTableException("Table does not exist: %s", identifier); } String cachedLocation = cachedTable instanceof HasTableOperations tableOps ? tableOps.operations().current().metadataFileLocation() : null; if (location.equals(cachedLocation)) { - onCacheHit(canonicalized); - l1MarkFresh(canonicalized, now); + onCacheHit(identifier); + l1MarkFresh(identifier, now); return cachedTable; } else { - LOG.debug("Invalidate table {}, cached {} != actual {}", canonicalized, cachedLocation, location); + LOG.debug("Invalidate table {}, cached {} != actual {}", identifier, cachedLocation, location); // Invalidate the cached table if the location is different - invalidateTable(canonicalized); - onCacheInvalidate(canonicalized); + invalidateTable(identifier); + onCacheInvalidate(identifier); } } else { - onCacheMiss(canonicalized); + onCacheMiss(identifier); } - final Table table = tableCache.get(canonicalized, this::loadTableWithoutCache); + final Table table = tableCache.get(identifier, this::loadTableWithoutCache); if (table instanceof BaseMetadataTable) { // Cache underlying table: there must be a table named by the namespace (?) - TableIdentifier originTableIdentifier = TableIdentifier.of(canonicalized.namespace().levels()); + TableIdentifier originTableIdentifier = TableIdentifier.of(identifier.namespace().levels()); Table originTable = tableCache.get(originTableIdentifier, this::loadTableWithoutCache); // Share TableOperations instance of origin table for all metadata tables, so that metadata // table instances are refreshed as well when origin table instance is refreshed. if (originTable instanceof HasTableOperations tableOps) { TableOperations ops = tableOps.operations(); - MetadataTableType type = MetadataTableType.from(canonicalized.name()); + MetadataTableType type = MetadataTableType.from(identifier.name()); // Defensive: MetadataTableType.from may return null for unknown names if (type != null) { Table metadataTable = - MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, canonicalized, type); - tableCache.put(canonicalized, metadataTable); - l1MarkFresh(canonicalized, now); - onCacheMetaLoad(canonicalized); - LOG.debug("Loaded metadata table: {} for origin table: {}", canonicalized, originTableIdentifier); + MetadataTableUtils.createMetadataTableInstance(ops, hiveCatalog.name(), originTableIdentifier, identifier, type); + tableCache.put(identifier, metadataTable); + l1MarkFresh(identifier, now); + onCacheMetaLoad(identifier); + LOG.debug("Loaded metadata table: {} for origin table: {}", identifier, originTableIdentifier); // Return the metadata table instead of the original table return metadataTable; } } } - l1MarkFresh(canonicalized, now); - onCacheLoad(canonicalized); + l1MarkFresh(identifier, now); + onCacheLoad(identifier); return table; } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java index f772ce85a7da..a6d0efc5c602 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java @@ -72,6 +72,20 @@ public String getPath() { * @return the catalog */ private Catalog createCatalog() { + final HiveCatalog hiveCatalog = createHiveCatalog(configuration); + long expiry = MetastoreConf.getLongVar(configuration, MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY); + return expiry > 0 ? new HMSCachingCatalog(hiveCatalog, expiry) : hiveCatalog; + } + + /** + * Builds the underlying {@link HiveCatalog} from the given configuration. + *

    Exposed so tests can obtain a catalog through the exact production construction path rather + * than duplicating it; the servlet path wraps the result in an {@link HMSCachingCatalog} when a + * positive cache expiry is configured (see {@link #createCatalog()}).

    + * @param configuration the configuration + * @return the initialized HiveCatalog + */ + public static HiveCatalog createHiveCatalog(Configuration configuration) { final Map properties = new TreeMap<>(); final String configUri = MetastoreConf.getVar(configuration, MetastoreConf.ConfVars.THRIFT_URIS); // Clear THRIFT_URIS so HiveCatalog doesn't accidentally use Thrift connection @@ -101,8 +115,7 @@ private Catalog createCatalog() { hiveCatalog.setConf(configuration); final String catalogName = MetastoreConf.getVar(configuration, MetastoreConf.ConfVars.CATALOG_DEFAULT); hiveCatalog.initialize(catalogName, properties); - long expiry = MetastoreConf.getLongVar(configuration, MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY); - return expiry > 0 ? new HMSCachingCatalog(hiveCatalog, expiry) : hiveCatalog; + return hiveCatalog; } /** diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java index 47df305ce3bd..16a811a94e17 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogCache.java @@ -74,9 +74,7 @@ class TestHMSCachingCatalogCache { @BeforeAll void setupAll() { - HMSCachingCatalog serverCatalog = HMSCachingCatalog.getLatestCache(null); - Assertions.assertNotNull(serverCatalog, "HMSCachingCatalog must be initialized by the server"); - hiveCatalog = serverCatalog.getCatalog(); + hiveCatalog = SERVER.newServerCatalog(); } @BeforeEach diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index 6e441096450f..3bd595907075 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -41,18 +41,24 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.RegisterExtension; /** - * Integration tests that verify the {@link HMSCachingCatalog} cache-statistics counters + * Component tests that verify the {@link HMSCachingCatalog} cache-statistics counters * (hit, miss, load, invalidate, l1-hit, l1-miss, and their rates) are updated correctly - * and exposed accurately via the JMX MBean registered under + * and exposed accurately via both the getters and the JMX MBean registered under * {@code org.apache.iceberg.rest:type=HMSCachingCatalog,name=*}. * + *

    Each test drives a freshly built {@link HMSCachingCatalog} directly (obtained through the + * server extension, which wraps a {@link HiveCatalog} built via the production + * {@link org.apache.iceberg.rest.HMSCatalogFactory} path). A fresh instance starts with all + * counters at zero, so assertions use absolute values rather than deltas.

    + * *

    The server is started with {@link AuthType#NONE} so the tests focus purely on - * caching behaviour without any authentication noise. + * caching behaviour without any authentication noise.

    */ @Category(MetastoreCheckinTest.class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -60,31 +66,38 @@ class TestHMSCachingCatalogStats { /** 5 minutes expressed in milliseconds – the value injected into {@code ICEBERG_CATALOG_CACHE_EXPIRY}. */ private static final long CACHE_EXPIRY_MS = 5 * 60 * 1_000L; + private static final String NS = "caching_stats_test_db"; + private static final Namespace NAMESPACE = Namespace.of(NS); + private static final String TABLE = "caching_stats_test_table"; + private static final TableIdentifier TABLE_ID = TableIdentifier.of(NAMESPACE, TABLE); @RegisterExtension - private static final HiveRESTCatalogServerExtension REST_CATALOG_EXTENSION = HiveRESTCatalogServerExtension.builder(AuthType.NONE) + private static final HiveRESTCatalogServerExtension SERVER = HiveRESTCatalogServerExtension.builder(AuthType.NONE) // Without a positive expiry the HMSCatalogFactory skips HMSCachingCatalog entirely. .configure(MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY.getVarname(), String.valueOf(CACHE_EXPIRY_MS)) .configure("hive.in.test", "true").build(); - private RESTCatalog catalog; - private HiveCatalog serverCatalog; - /** The server-side {@link HMSCachingCatalog} instance; used to invalidate entries directly. */ - private HMSCachingCatalog serverCachingCatalog; - /** The platform {@link MBeanServer} used for all JMX-based assertions. */ + /** Underlying catalog, shared across tests; used for setup and direct (uncached) mutations. */ + private HiveCatalog hiveCatalog; + /** The caching catalog under test; rebuilt fresh for every test so counters start at zero. */ + private HMSCachingCatalog catalog; + /** The platform {@link MBeanServer} used for JMX-based assertions. */ private MBeanServer mbs; - /** Resolved once in {@link #setupAll()} and reused across every test. */ + /** The JMX ObjectName registered by the current {@link #catalog} instance. */ private ObjectName jmxObjectName; @BeforeAll - void setupAll() throws Exception { - catalog = RCKUtils.initCatalogClient(java.util.Map.of("uri", REST_CATALOG_EXTENSION.getRestEndpoint())); - serverCachingCatalog = HMSCachingCatalog.getLatestCache(null); - Assertions.assertNotNull(serverCachingCatalog, "Expected HMSCachingCatalog to be initialized"); - serverCatalog = serverCachingCatalog.getCatalog(); - - // Resolve the JMX ObjectName registered by HMSCachingCatalog. We use a wildcard - // so the test is independent of the exact catalog name. + void setupAll() { + hiveCatalog = SERVER.newServerCatalog(); + } + + @BeforeEach + void setupEach() throws Exception { + catalog = new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS); + hiveCatalog.createNamespace(NAMESPACE); + + // Resolve the JMX ObjectName registered by the catalog instance just created. We use a + // wildcard so the test is independent of the exact catalog name. mbs = ManagementFactory.getPlatformMBeanServer(); Set names = mbs.queryNames( new ObjectName("org.apache.iceberg.rest:type=HMSCachingCatalog,*"), null); @@ -96,43 +109,30 @@ void setupAll() throws Exception { /** Remove any namespace/table created by the test so each run starts clean. */ @AfterEach void cleanup() { - RCKUtils.purgeCatalogTestEntries(catalog); + try { + hiveCatalog.dropTable(TABLE_ID, false); + } catch (Exception ignored) { + // table may not exist + } + try { + hiveCatalog.dropNamespace(NAMESPACE); + } catch (Exception ignored) { + // namespace may not exist + } } // --------------------------------------------------------------------------- - // helpers + // JMX helpers // --------------------------------------------------------------------------- - /** - * Reads a single JMX attribute from the {@link HMSCachingCatalogMXBean}. - * - * @param attribute the attribute name as declared in {@link HMSCachingCatalogMXBean} - * (e.g. {@code "CacheHitCount"}) - * @return the attribute value - */ - private Object getJmxAttribute(String attribute) throws Exception { - return mbs.getAttribute(jmxObjectName, attribute); - } - - /** - * Convenience wrapper that reads a {@code long} JMX attribute. - */ private long jmxLong(String attribute) throws Exception { - return (long) getJmxAttribute(attribute); + return (long) mbs.getAttribute(jmxObjectName, attribute); } - /** - * Convenience wrapper that reads a {@code double} JMX attribute. - */ private double jmxDouble(String attribute) throws Exception { - return (double) getJmxAttribute(attribute); + return (double) mbs.getAttribute(jmxObjectName, attribute); } - /** - * Invokes a void JMX operation on the {@link HMSCachingCatalogMXBean}. - * - * @param operationName the operation name (e.g. {@code "resetCacheStats"}) - */ private void invokeJmxOperation(String operationName) throws Exception { mbs.invoke(jmxObjectName, operationName, new Object[0], new String[0]); } @@ -143,22 +143,9 @@ private void invokeJmxOperation(String operationName) throws Exception { /** * Verifies that the {@link HMSCachingCatalog} correctly tracks cache hits, misses, - * loads, invalidations, L1 hits, and L1 misses via JMX. + * loads, invalidations, L1 hits, and L1 misses. * - *

    Strategy: - *

      - *
    1. Snapshot JMX baseline counters before any operations so the test is isolated - * from cumulative state left by previous tests.
    2. - *
    3. Create a namespace and a table.
    4. - *
    5. First {@code loadTable} call → cache miss + actual load.
    6. - *
    7. Second and third rapid {@code loadTable} calls → L1 cache hits (TTL still valid).
    8. - *
    9. Mutate the table to advance its metadata location in HMS.
    10. - *
    11. Wait for the L1 TTL to expire, then reload → L1 miss + invalidation + reload.
    12. - *
    13. Assert JMX counter deltas match expectations.
    14. - *
    - */ - /** - * Counter states for the four {@code loadTable} calls in {@link #testCacheCountersAreUpdated}: + *

    Counter states for the four {@code loadTable} calls: *

        *   Call 1 – cold L2 miss : onCacheMiss  + onCacheLoad               → miss=1, load=1
        *   Call 2 – L1 hit       : onL1CacheHit + onCacheHit                 → l1Hit=1, hit=1
    @@ -174,101 +161,78 @@ private void invokeJmxOperation(String operationName) throws Exception {
        */
       @Test
       void testCacheCountersAreUpdated() throws Exception {
    -    // -- JMX baseline -----------------------------------------------------------
    -    long baseHit      = jmxLong("CacheHitCount");
    -    long baseMiss     = jmxLong("CacheMissCount");
    -    long baseLoad     = jmxLong("CacheLoadCount");
    -    long baseL1Hit    = jmxLong("L1CacheHitCount");
    -    long baseL1Miss   = jmxLong("L1CacheMissCount");
    -
    -    // -- exercise the cache -----------------------------------------------------
    -    var db = Namespace.of("caching_stats_test_db");
    -    var tableId = TableIdentifier.of(db, "caching_stats_test_table");
    -
    -    catalog.createNamespace(db);
    -    Table created = catalog.createTable(tableId, new Schema());
    +    Table created = hiveCatalog.createTable(TABLE_ID, new Schema());
     
         // First load  → cache miss + load; must return the table we just created.
    -    Table firstLoad = catalog.loadTable(tableId);
    +    Table firstLoad = catalog.loadTable(TABLE_ID);
         Assertions.assertEquals(created.location(), firstLoad.location(),
             "First load must return the table we just created");
         // Second load → L1 hit  (within TTL, HMS location check skipped)
    -    catalog.loadTable(tableId);
    +    catalog.loadTable(TABLE_ID);
         // Third load  → L1 hit
    -    catalog.loadTable(tableId);
    +    catalog.loadTable(TABLE_ID);
     
    -    // Mutate the table by appending a data file – this creates a new snapshot
    -    // which advances METADATA_LOCATION in HMS, so the next loadTable call through
    -    // the caching catalog will detect the stale cached location and invalidate it.
    -    Table table = serverCatalog.loadTable(tableId);
    +    // Mutate the table by appending a data file – this creates a new snapshot which advances
    +    // METADATA_LOCATION in HMS, so the next loadTable call through the caching catalog will detect
    +    // the stale cached location and invalidate it.
    +    Table table = hiveCatalog.loadTable(TABLE_ID);
         DataFile dataFile = DataFiles.builder(PartitionSpec.unpartitioned())
             .withPath(table.location() + "/data/fake-0.parquet")
             .withFileSizeInBytes(1024).withRecordCount(1).build();
         table.newAppend().appendFile(dataFile).commit();
     
    -    long baseInvalidate = jmxLong("CacheInvalidateCount");
         // Default L1 TTL is 3 000 ms; sleep 3 500 ms to ensure the entry is expired.
         Thread.sleep(3_500);
         // Fourth load → L1 miss + cache invalidation + reload
    -    Table reloaded = catalog.loadTable(tableId);
    -
    -    // -- JMX counter assertions (exact values; see Javadoc above for derivation) -
    -    long deltaHit      = jmxLong("CacheHitCount")      - baseHit;
    -    long deltaMiss     = jmxLong("CacheMissCount")      - baseMiss;
    -    long deltaLoad     = jmxLong("CacheLoadCount")      - baseLoad;
    -    long deltaInvalidate = jmxLong("CacheInvalidateCount") - baseInvalidate;
    -    long deltaL1Hit    = jmxLong("L1CacheHitCount")     - baseL1Hit;
    -    long deltaL1Miss   = jmxLong("L1CacheMissCount")    - baseL1Miss;
    -
    -    Assertions.assertEquals(1L, deltaMiss,
    -        "Expected exactly 1 cache miss (cold load on call 1), but delta was: " + deltaMiss);
    -    Assertions.assertEquals(2L, deltaLoad,
    -        "Expected exactly 2 cache loads (call 1 + post-invalidation call 4), but delta was: " + deltaLoad);
    -    Assertions.assertEquals(2L, deltaHit,
    -        "Expected exactly 2 cache hits (calls 2 and 3), but delta was: " + deltaHit);
    -    Assertions.assertEquals(1L, deltaInvalidate,
    -        "Expected exactly 1 cache invalidation (metadata location changed on call 4), but delta was: " + deltaInvalidate);
    -    Assertions.assertEquals(2L, deltaL1Hit,
    -        "Expected exactly 2 L1 hits (calls 2 and 3, within TTL), but delta was: " + deltaL1Hit);
    -    Assertions.assertEquals(1L, deltaL1Miss,
    -        "Expected exactly 1 L1 miss (call 4, after TTL expiry), but delta was: " + deltaL1Miss);
    +    Table reloaded = catalog.loadTable(TABLE_ID);
    +
    +    // -- counter assertions (exact values; see Javadoc above for derivation) --
    +    Assertions.assertEquals(1L, catalog.getCacheMissCount(),
    +        "Expected exactly 1 cache miss (cold load on call 1)");
    +    Assertions.assertEquals(2L, catalog.getCacheLoadCount(),
    +        "Expected exactly 2 cache loads (call 1 + post-invalidation call 4)");
    +    Assertions.assertEquals(2L, catalog.getCacheHitCount(),
    +        "Expected exactly 2 cache hits (calls 2 and 3)");
    +    Assertions.assertEquals(1L, catalog.getCacheInvalidateCount(),
    +        "Expected exactly 1 cache invalidation (metadata location changed on call 4)");
    +    Assertions.assertEquals(2L, catalog.getL1CacheHitCount(),
    +        "Expected exactly 2 L1 hits (calls 2 and 3, within TTL)");
    +    Assertions.assertEquals(1L, catalog.getL1CacheMissCount(),
    +        "Expected exactly 1 L1 miss (call 4, after TTL expiry)");
     
         // The reloaded table must reflect the new snapshot created by the append above;
         // this confirms the staleness-detection path returned fresh data, not the stale cache entry.
         Assertions.assertNotNull(reloaded.currentSnapshot(),
             "Staleness detection must have reloaded the table with its new snapshot");
     
    -    // Rate attributes must be valid ratios in [0.0, 1.0].
    -    double hitRate = jmxDouble("CacheHitRate");
    +    // Rate attributes must be valid ratios in (0.0, 1.0].
    +    double hitRate = catalog.getCacheHitRate();
         Assertions.assertTrue(hitRate > 0.0 && hitRate <= 1.0,
             "CacheHitRate must be in (0.0, 1.0] but was: " + hitRate);
    -
    -    double l1HitRate = jmxDouble("L1CacheHitRate");
    +    double l1HitRate = catalog.getL1CacheHitRate();
         Assertions.assertTrue(l1HitRate > 0.0 && l1HitRate <= 1.0,
             "L1CacheHitRate must be in (0.0, 1.0] but was: " + l1HitRate);
       }
     
       /**
    -   * Verifies that the {@code resetCacheStats} JMX operation zeroes all counters.
    +   * Verifies that the {@code resetCacheStats} JMX operation zeroes all counters, and that the
    +   * getters and JMX attributes report the same values.
        *
        * 

    Strategy: *

      - *
    1. Perform some cache operations to ensure all counters are non-zero.
    2. + *
    3. Perform some cache operations to ensure counters are non-zero.
    4. *
    5. Invoke {@code resetCacheStats()} via JMX.
    6. *
    7. Assert that every JMX counter attribute reads {@code 0} / {@code 0.0}.
    8. + *
    9. Drive further loads and confirm the counters resume from zero.
    10. *
    */ @Test void testJmxResetCacheStats() throws Exception { - // -- warm up counters ------------------------------------------------------- - var db = Namespace.of("jmx_reset_test_db"); - var tableId = TableIdentifier.of(db, "jmx_reset_test_table"); - catalog.createNamespace(db); - Table created = catalog.createTable(tableId, new Schema()); - Table loaded = catalog.loadTable(tableId); // miss + load + Table created = hiveCatalog.createTable(TABLE_ID, new Schema()); + Table loaded = catalog.loadTable(TABLE_ID); // miss + load Assertions.assertEquals(created.location(), loaded.location(), "Warm-up load must return the table we just created"); - catalog.loadTable(tableId); // hit (L1 hit on the fast path) + catalog.loadTable(TABLE_ID); // hit (L1 hit on the fast path) // Sanity: at least one counter must be non-zero before the reset. Assertions.assertTrue(jmxLong("CacheHitCount") + jmxLong("CacheMissCount") > 0, @@ -289,19 +253,15 @@ void testJmxResetCacheStats() throws Exception { Assertions.assertEquals(0.0, jmxDouble("L1CacheHitRate"), 1e-9, "L1CacheHitRate must be 0.0 after reset"); // -- verify rate calculation still works correctly after reset -------------- - // resetCacheStats() zeroes counters but does NOT evict the L2/L1 cache, so the - // table is still cached. Invalidate it directly on the server-side HMSCachingCatalog - // so the first post-reset load is a genuine cold miss rather than an L1/L2 hit. - // NOTE: catalog.invalidateTable() only clears the REST *client* state and does not - // reach the server-side cache. - serverCachingCatalog.invalidateTable(tableId); + // resetCacheStats() zeroes counters but does NOT evict the L2/L1 cache, so the table is still + // cached. Invalidate it so the first post-reset load is a genuine cold miss rather than a hit. + catalog.invalidateTable(TABLE_ID); // First load after reset: cache miss + load (L1 cold, L2 cold). - catalog.loadTable(tableId); - // Second load: L1 hit (within TTL). - catalog.loadTable(tableId); - // Third load: L1 hit (within TTL). - catalog.loadTable(tableId); + catalog.loadTable(TABLE_ID); + // Second and third loads: L1 hits (within TTL). + catalog.loadTable(TABLE_ID); + catalog.loadTable(TABLE_ID); // CacheHitRate: 2 hits out of 3 total accesses → ≈ 0.667 double hitRateAfterReset = jmxDouble("CacheHitRate"); @@ -318,7 +278,6 @@ void testJmxResetCacheStats() throws Exception { double l1HitRateAfterReset = jmxDouble("L1CacheHitRate"); Assertions.assertTrue(l1HitRateAfterReset > 0.0 && l1HitRateAfterReset <= 1.0, "L1CacheHitRate must be in (0.0, 1.0] after post-reset L1 hits, but was: " + l1HitRateAfterReset); - Assertions.assertTrue(jmxLong("L1CacheHitCount") >= 2, "L1CacheHitCount must be >= 2 after two rapid re-loads within TTL post-reset"); } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/HiveRESTCatalogServerExtension.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/HiveRESTCatalogServerExtension.java index 03374c6072ff..e0fd0fd1885c 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/HiveRESTCatalogServerExtension.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/HiveRESTCatalogServerExtension.java @@ -31,6 +31,9 @@ import org.apache.hadoop.hive.metastore.ServletSecurity.AuthType; import org.apache.hadoop.hive.metastore.conf.MetastoreConf; import org.apache.hadoop.hive.metastore.conf.MetastoreConf.ConfVars; +import org.apache.iceberg.hive.HiveCatalog; +import org.apache.iceberg.rest.HMSCachingCatalog; +import org.apache.iceberg.rest.HMSCatalogFactory; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; @@ -130,6 +133,32 @@ public String getRestEndpoint() { return restCatalogServer.getRestEndpoint(); } + /** + * Builds a fresh {@link HiveCatalog} bound to the embedded metastore through the same production + * path the server uses ({@link HMSCatalogFactory#createHiveCatalog}). The Thrift URI is taken from + * the port the metastore actually bound to, so callers get a working client regardless of how the + * server's own configuration was mutated at startup. + * + * @return a newly initialized HiveCatalog + */ + public HiveCatalog newServerCatalog() { + Configuration catalogConf = new Configuration(conf); + MetastoreConf.setVar(catalogConf, ConfVars.THRIFT_URIS, restCatalogServer.getThriftUri()); + return HMSCatalogFactory.createHiveCatalog(catalogConf); + } + + /** + * Wraps a fresh {@link #newServerCatalog()} in an {@link HMSCachingCatalog} with the given L2 + * expiry, so tests can exercise the caching catalog directly without reaching into the server's + * own instance. + * + * @param expiryMs the L2 cache expiry in milliseconds + * @return a newly created caching catalog + */ + public HMSCachingCatalog newCachingCatalog(long expiryMs) { + return new HMSCachingCatalog(newServerCatalog(), expiryMs); + } + public String getOAuth2TokenEndpoint() { return authorizationServer.getTokenEndpoint(); } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/RESTCatalogServer.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/RESTCatalogServer.java index f04f5c22ccc7..1512e5e834b1 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/RESTCatalogServer.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/RESTCatalogServer.java @@ -108,4 +108,9 @@ public Path getWarehouseDir() { public String getRestEndpoint() { return String.format("http://localhost:%d/iceberg", restPort); } + + /** Thrift URI of the embedded metastore, built from the port it actually bound to. */ + public String getThriftUri() { + return String.format("thrift://localhost:%d", hmsPort); + } } From a495197940c727e27cd6da1be3162142a6437196 Mon Sep 17 00:00:00 2001 From: Henrib Date: Sun, 16 Aug 2026 13:36:33 +0200 Subject: [PATCH 16/20] HIVE-29817: enforce read/list authorization at the Iceberg REST choke point Authorize loadTable/loadView (QUERY) in HMSCatalogAdapter so cache-served reads authorize identically to HMS-served ones, and result-filter listTables/listViews/listNamespaces via filterListCmdObjects so users see only what they may read. Writes and stage-create authz unchanged. --- .../iceberg/rest/HMSCatalogAdapter.java | 140 +++++--------- .../iceberg/rest/IcebergAuthorizer.java | 173 +++++++++++++++++- .../iceberg/rest/BaseRESTCatalogTests.java | 15 +- .../iceberg/rest/TestIcebergAuthorizer.java | 159 +++++++++++++++- .../rest/extension/MockHiveAuthorizer.java | 7 +- 5 files changed, 390 insertions(+), 104 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java index ca87a1951dc0..6ef341a8e522 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java @@ -24,7 +24,6 @@ import java.io.IOException; import java.time.Clock; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Map; import javax.servlet.http.HttpServletResponse; @@ -241,7 +240,12 @@ private ListNamespacesResponse listNamespaces(Map vars) { } else { namespace = Namespace.empty(); } - return castResponse(ListNamespacesResponse.class, CatalogHandlers.listNamespaces(asNamespaceCatalog, namespace)); + ListNamespacesResponse response = castResponse( + ListNamespacesResponse.class, CatalogHandlers.listNamespaces(asNamespaceCatalog, namespace)); + return ListNamespacesResponse.builder() + .addAll(icebergAuthorizer.filterNamespaces(catalogName, response.namespaces())) + .nextPageToken(response.nextPageToken()) + .build(); } private CreateNamespaceResponse createNamespace(Object body) { @@ -278,7 +282,12 @@ private UpdateNamespacePropertiesResponse updateNamespace(Map va private ListTablesResponse listTables(Map vars) { Namespace namespace = namespaceFromPathVars(vars); - return castResponse(ListTablesResponse.class, CatalogHandlers.listTables(catalog, namespace)); + ListTablesResponse response = + castResponse(ListTablesResponse.class, CatalogHandlers.listTables(catalog, namespace)); + return ListTablesResponse.builder() + .addAll(icebergAuthorizer.filterTables(catalogName, response.identifiers())) + .nextPageToken(response.nextPageToken()) + .build(); } private LoadTableResponse createTable(Map vars, Object body) { @@ -314,6 +323,7 @@ private RESTResponse tableExists(Map vars) { private LoadTableResponse loadTable(Map vars) { TableIdentifier ident = identFromPathVars(vars); + icebergAuthorizer.authorizeLoadTable(catalogName, ident); return castResponse(LoadTableResponse.class, CatalogHandlers.loadTable(catalog, ident)); } @@ -353,14 +363,14 @@ private ListTablesResponse listViews(Map vars) { Namespace namespace = namespaceFromPathVars(vars); String pageToken = PropertyUtil.propertyAsString(vars, "pageToken", null); String pageSize = PropertyUtil.propertyAsString(vars, "pageSize", null); - if (pageSize != null) { - return castResponse( - ListTablesResponse.class, - CatalogHandlers.listViews(asViewCatalog, namespace, pageToken, pageSize)); - } else { - return castResponse( - ListTablesResponse.class, CatalogHandlers.listViews(asViewCatalog, namespace)); - } + ListTablesResponse response = pageSize != null + ? castResponse(ListTablesResponse.class, + CatalogHandlers.listViews(asViewCatalog, namespace, pageToken, pageSize)) + : castResponse(ListTablesResponse.class, CatalogHandlers.listViews(asViewCatalog, namespace)); + return ListTablesResponse.builder() + .addAll(icebergAuthorizer.filterViews(catalogName, response.identifiers())) + .nextPageToken(response.nextPageToken()) + .build(); } private LoadViewResponse createView(Map vars, Object body) { @@ -378,6 +388,7 @@ private RESTResponse viewExists(Map vars) { private LoadViewResponse loadView(Map vars) { TableIdentifier ident = viewIdentFromPathVars(vars); + icebergAuthorizer.authorizeLoadView(catalogName, ident); return castResponse(LoadViewResponse.class, CatalogHandlers.loadView(asViewCatalog, ident)); } @@ -437,85 +448,34 @@ private static void commitTransaction(Catalog catalog, CommitTransactionRequest @SuppressWarnings({"MethodLength", "unchecked"}) private T handleRequest( Route route, Map vars, Object body) { - switch (route) { - case CONFIG: - return (T) config(); - - case LIST_NAMESPACES: - return (T) listNamespaces(vars); - - case CREATE_NAMESPACE: - return (T) createNamespace(body); - - case NAMESPACE_EXISTS: - return (T) namespaceExists(vars); - - case LOAD_NAMESPACE: - return (T) loadNamespace(vars); - - case DROP_NAMESPACE: - return (T) dropNamespace(vars); - - case UPDATE_NAMESPACE: - return (T) updateNamespace(vars, body); - - case LIST_TABLES: - return (T) listTables(vars); - - case CREATE_TABLE: - return (T) createTable(vars, body); - - case DROP_TABLE: - return (T) dropTable(vars); - - case TABLE_EXISTS: - return (T) tableExists(vars); - - case LOAD_TABLE: - return (T) loadTable(vars); - - case REGISTER_TABLE: - return (T) registerTable(vars, body); - - case UPDATE_TABLE: - return (T) updateTable(vars, body); - - case RENAME_TABLE: - return (T) renameTable(body); - - case REPORT_METRICS: - return (T) reportMetrics(vars, body); - - case COMMIT_TRANSACTION: - return (T) commitTransaction(body); - - case LIST_VIEWS: - return (T) listViews(vars); - - case CREATE_VIEW: - return (T) createView(vars, body); - - case VIEW_EXISTS: - return (T) viewExists(vars); - - case LOAD_VIEW: - return (T) loadView(vars); - - case UPDATE_VIEW: - return (T) updateView(vars, body); - - case RENAME_VIEW: - return (T) renameView(body); - - case DROP_VIEW: - return (T) dropView(vars); - - case REGISTER_VIEW: - return (T) registerView(vars, body); - - default: - } - return null; + return switch (route) { + case CONFIG -> (T) config(); + case LIST_NAMESPACES -> (T) listNamespaces(vars); + case CREATE_NAMESPACE -> (T) createNamespace(body); + case NAMESPACE_EXISTS -> (T) namespaceExists(vars); + case LOAD_NAMESPACE -> (T) loadNamespace(vars); + case DROP_NAMESPACE -> (T) dropNamespace(vars); + case UPDATE_NAMESPACE -> (T) updateNamespace(vars, body); + case LIST_TABLES -> (T) listTables(vars); + case CREATE_TABLE -> (T) createTable(vars, body); + case DROP_TABLE -> (T) dropTable(vars); + case TABLE_EXISTS -> (T) tableExists(vars); + case LOAD_TABLE -> (T) loadTable(vars); + case REGISTER_TABLE -> (T) registerTable(vars, body); + case UPDATE_TABLE -> (T) updateTable(vars, body); + case RENAME_TABLE -> (T) renameTable(body); + case REPORT_METRICS -> (T) reportMetrics(vars, body); + case COMMIT_TRANSACTION -> (T) commitTransaction(body); + case LIST_VIEWS -> (T) listViews(vars); + case CREATE_VIEW -> (T) createView(vars, body); + case VIEW_EXISTS -> (T) viewExists(vars); + case LOAD_VIEW -> (T) loadView(vars); + case UPDATE_VIEW -> (T) updateView(vars, body); + case RENAME_VIEW -> (T) renameView(body); + case DROP_VIEW -> (T) dropView(vars); + case REGISTER_VIEW -> (T) registerView(vars, body); + default -> null; + }; } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java index 2df051105b77..296e49e795af 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java @@ -22,8 +22,10 @@ import static org.apache.iceberg.hive.HiveCatalog.HMS_DB_OWNER_TYPE; import static org.apache.iceberg.hive.HiveCatalog.HMS_TABLE_OWNER; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -44,7 +46,9 @@ import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveOperationType; import org.apache.hadoop.hive.ql.security.authorization.plugin.HivePrivilegeObject; import org.apache.hadoop.hive.ql.security.authorization.plugin.metastore.HiveMetaStoreAuthorizer; +import org.apache.iceberg.MetadataTableType; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.hive.HiveHadoopUtil; import org.apache.iceberg.rest.requests.CreateTableRequest; @@ -151,14 +155,177 @@ void validateStageCreateTable(String catalogName, Namespace namespace, Map filterTables(String catalogName, List identifiers) { + return filterTableOrViews(catalogName, identifiers, "show tables"); + } + + /** + * Filters a view listing down to the entries the user may see. See {@link #filterTables}. + * + * @param catalogName the Hive catalog name + * @param identifiers the full listing to filter + * @return the subset the user is allowed to see, sorted by name + * @throws IllegalStateException if the authorization plugin fails + */ + List filterViews(String catalogName, List identifiers) { + return filterTableOrViews(catalogName, identifiers, "show views"); + } + + private List filterTableOrViews(String catalogName, List identifiers, + String commandString) { + var authorizer = authorizerSupplier.get(); + if (authorizer == null) { + return identifiers; + } + List objects = new ArrayList<>(identifiers.size()); + for (TableIdentifier identifier : identifiers) { + objects.add(tableOrView(catalogName, identifier)); + } + List allowed = filterListCmd(authorizer, objects, commandString); + List result = new ArrayList<>(allowed.size()); + for (HivePrivilegeObject object : allowed) { + result.add(TableIdentifier.of(object.getDbname(), object.getObjectName())); + } + result.sort(Comparator.comparing(TableIdentifier::name)); + return result; + } + + /** + * Filters a namespace listing down to the databases the user may see, mirroring how Hive filters + * a {@code SHOW DATABASES} result. Hive Metastore performs no pre-event authorization for + * {@code get_databases}, so without this filter a user would see databases they cannot access. + * Multi-level namespaces cannot map to a Hive database and are dropped rather than failing the + * whole listing. + * + * @param catalogName the Hive catalog name + * @param namespaces the full listing to filter + * @return the subset the user is allowed to see, sorted by name + * @throws IllegalStateException if the authorization plugin fails + */ + List filterNamespaces(String catalogName, List namespaces) { + var authorizer = authorizerSupplier.get(); + if (authorizer == null) { + return namespaces; + } + // Only single-level namespaces map to a Hive database; multi-level ones are dropped. + List objects = new ArrayList<>(namespaces.size()); + for (Namespace namespace : namespaces) { + if (namespace.levels().length == 1) { + objects.add(database(catalogName, namespace)); + } + } + List allowed = filterListCmd(authorizer, objects, "show databases"); + List result = new ArrayList<>(allowed.size()); + for (HivePrivilegeObject object : allowed) { + result.add(Namespace.of(object.getDbname())); + } + result.sort(Comparator.comparing(namespace -> namespace.level(0))); + return result; + } + + private List filterListCmd(HiveAuthorizer authorizer, List objects, + String commandString) { + var builder = new HiveAuthzContext.Builder(); + builder.setCommandString(commandString); + try { + List allowed = authorizer.filterListCmdObjects(objects, builder.build()); + return allowed == null ? List.of() : allowed; + } catch (HiveAccessControlException e) { + throw new ForbiddenException(e, e.getMessage()); + } catch (HiveAuthzPluginException e) { + throw new IllegalStateException("Failed to filter " + commandString + " results", e); + } + } + + /** + * Normalizes a metadata-table identifier ({@code db.table.}) to its base table + * ({@code db.table}) so access cannot be granted through a metadata-table name. Non-metadata + * identifiers are returned unchanged. + */ + private static TableIdentifier baseTableIdentifier(TableIdentifier identifier) { + String[] levels = identifier.namespace().levels(); + // A metadata-table identifier is db.table., so its namespace always carries at least the + // parent database and table. A single-level namespace whose name happens to match a metadata + // type is a real table, not a metadata table, and must be left untouched. + if (levels.length >= 2 && MetadataTableType.from(identifier.name()) != null) { + return TableIdentifier.of(levels); + } + return identifier; + } + + private static HivePrivilegeObject tableOrView(String catalogName, TableIdentifier identifier) { + return new HivePrivilegeObject(HivePrivilegeObject.HivePrivilegeObjectType.TABLE_OR_VIEW, catalogName, + identifier.namespace().level(0), identifier.name()); + } + + private static HivePrivilegeObject database(String catalogName, Namespace namespace) { + Preconditions.checkArgument(namespace.levels().length == 1, "Hive does not support multi-level namespaces"); + return new HivePrivilegeObject(HivePrivilegeObject.HivePrivilegeObjectType.DATABASE, catalogName, + namespace.level(0), (String) null); + } + + private void check(HiveOperationType operation, List inputs, + List outputs, String commandString) { + check(authorizerSupplier.get(), operation, inputs, outputs, commandString); + } + + private void check(HiveAuthorizer authorizer, HiveOperationType operation, List inputs, + List outputs, String commandString) { + if (authorizer == null) { + LOG.debug("No pre-event listener is configured, skipping {} authorization", operation); + return; + } var builder = new HiveAuthzContext.Builder(); - builder.setCommandString("create table " + request.name()); + builder.setCommandString(commandString); try { - authorizer.checkPrivileges(HiveOperationType.CREATETABLE, inputs, outputs, builder.build()); + authorizer.checkPrivileges(operation, inputs, outputs, builder.build()); } catch (HiveAccessControlException e) { throw new ForbiddenException(e, e.getMessage()); } catch (HiveAuthzPluginException e) { - throw new IllegalStateException("Failed to check privileges stage-create", e); + throw new IllegalStateException("Failed to check privileges for " + operation, e); } } } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java index 206d72c9cb1b..4d2e0a5506c9 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/BaseRESTCatalogTests.java @@ -125,8 +125,9 @@ void testPermissionsWithDeniedUser() throws Exception { throw new AssertionError("Catalog operation failed", e); } try (var client = RCKUtils.initCatalogClient(properties.get())) { - // Should this fail? - Assertions.assertTrue(client.listNamespaces().contains(db)); + // List operations are result-filtered: a user who can read nothing sees an empty listing + // rather than an error. + Assertions.assertFalse(client.listNamespaces().contains(db)); testUnauthorizedAccess(() -> client.namespaceExists(db)); testUnauthorizedAccess(() -> client.loadNamespaceMetadata(db)); testUnauthorizedAccess(() -> client.createNamespace(Namespace.of("new-db"))); @@ -134,16 +135,14 @@ void testPermissionsWithDeniedUser() throws Exception { testUnauthorizedAccess(() -> client.setProperties(db, Collections.singletonMap("key", "value"))); testUnauthorizedAccess(() -> client.removeProperties(db, Collections.singleton("key"))); - // Should this fail? - Assertions.assertEquals(Collections.singletonList(table), client.listTables(db)); + Assertions.assertTrue(client.listTables(db).isEmpty()); testUnauthorizedAccess(() -> client.tableExists(table)); testUnauthorizedAccess(() -> client.loadTable(table)); testUnauthorizedAccess(() -> client.createTable(TableIdentifier.of(db, "new-table"), new Schema())); testUnauthorizedAccess(() -> client.renameTable(table, TableIdentifier.of(db, "new-table"))); testUnauthorizedAccess(() -> client.dropTable(table)); - // Should this fail? - Assertions.assertEquals(Collections.singletonList(view), client.listViews(db)); + Assertions.assertTrue(client.listViews(db).isEmpty()); testUnauthorizedAccess(() -> client.viewExists(view)); testUnauthorizedAccess(() -> client.loadView(view)); testUnauthorizedAccess(() -> client.buildView(TableIdentifier.of(db, "new-view")) @@ -184,7 +183,7 @@ void testPermissionsWithReadOnlyUser() throws Exception { throw new AssertionError("Catalog operation failed", e); } try (var client = RCKUtils.initCatalogClient(properties.get())) { - // Should this fail? + // A read-only user can read, so result-filtered listings still show what they may read. Assertions.assertTrue(client.listNamespaces().contains(db)); Assertions.assertTrue(client.namespaceExists(db)); Assertions.assertNotNull(client.loadNamespaceMetadata(db)); @@ -193,7 +192,6 @@ void testPermissionsWithReadOnlyUser() throws Exception { testUnauthorizedAccess(() -> client.setProperties(db, Collections.singletonMap("key", "value"))); testUnauthorizedAccess(() -> client.removeProperties(db, Collections.singleton("key"))); - // Should this fail? Assertions.assertEquals(Collections.singletonList(table), client.listTables(db)); Assertions.assertTrue(client.tableExists(table)); Assertions.assertNotNull(client.loadTable(table)); @@ -201,7 +199,6 @@ void testPermissionsWithReadOnlyUser() throws Exception { testUnauthorizedAccess(() -> client.renameTable(table, TableIdentifier.of(db, "new-table"))); testUnauthorizedAccess(() -> client.dropTable(table)); - // Should this fail? Assertions.assertEquals(Collections.singletonList(view), client.listViews(db)); Assertions.assertTrue(client.viewExists(view)); Assertions.assertNotNull(client.loadView(view)); diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java index 0d13414a0074..bf51c37d8f27 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hive.metastore.api.PrincipalType; import org.apache.hadoop.hive.metastore.conf.MetastoreConf; @@ -45,6 +46,7 @@ import org.apache.hadoop.security.UserGroupInformation; import org.apache.iceberg.Schema; import org.apache.iceberg.catalog.Namespace; +import org.apache.iceberg.catalog.TableIdentifier; import org.apache.iceberg.exceptions.ForbiddenException; import org.apache.iceberg.rest.extension.MockHiveAuthorizer; import org.apache.iceberg.rest.extension.MockHiveAuthorizerFactory; @@ -252,6 +254,161 @@ void testValidateStageCreateTableRejected() throws Exception { Assertions.assertSame(failure, exception.getCause()); } + @Test + @SuppressWarnings("unchecked") + void testAuthorizeLoadTable() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + icebergAuthorizer.authorizeLoadTable(CATALOG_NAME, TableIdentifier.of(NAMESPACE, TABLE_NAME)); + + var operation = ArgumentCaptor.forClass(HiveOperationType.class); + var inputs = ArgumentCaptor.forClass(List.class); + var outputs = ArgumentCaptor.forClass(List.class); + verify(hiveAuthorizer).checkPrivileges(operation.capture(), inputs.capture(), outputs.capture(), any()); + + Assertions.assertEquals(HiveOperationType.QUERY, operation.getValue()); + Assertions.assertEquals(List.of(), outputs.getValue()); + Assertions.assertEquals(1, inputs.getValue().size()); + var input = (HivePrivilegeObject) inputs.getValue().getFirst(); + assertThat(input.getType()).isEqualTo(HivePrivilegeObjectType.TABLE_OR_VIEW); + assertThat(input.getCatName()).isEqualTo(CATALOG_NAME); + assertThat(input.getDbname()).isEqualTo(NAMESPACE.level(0)); + assertThat(input.getObjectName()).isEqualTo(TABLE_NAME); + } + + @Test + @SuppressWarnings("unchecked") + void testAuthorizeLoadTableNormalizesMetadataTable() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + // A metadata-table identifier db.table.snapshots must be checked against its base table. + var metadataTable = TableIdentifier.of(Namespace.of(NAMESPACE.level(0), TABLE_NAME), "snapshots"); + icebergAuthorizer.authorizeLoadTable(CATALOG_NAME, metadataTable); + + var inputs = ArgumentCaptor.forClass(List.class); + verify(hiveAuthorizer).checkPrivileges(any(), inputs.capture(), anyList(), any()); + var input = (HivePrivilegeObject) inputs.getValue().getFirst(); + assertThat(input.getType()).isEqualTo(HivePrivilegeObjectType.TABLE_OR_VIEW); + assertThat(input.getDbname()).isEqualTo(NAMESPACE.level(0)); + assertThat(input.getObjectName()).isEqualTo(TABLE_NAME); + } + + @Test + @SuppressWarnings("unchecked") + void testAuthorizeLoadView() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + icebergAuthorizer.authorizeLoadView(CATALOG_NAME, TableIdentifier.of(NAMESPACE, "a_view")); + + var operation = ArgumentCaptor.forClass(HiveOperationType.class); + var inputs = ArgumentCaptor.forClass(List.class); + verify(hiveAuthorizer).checkPrivileges(operation.capture(), inputs.capture(), anyList(), any()); + Assertions.assertEquals(HiveOperationType.QUERY, operation.getValue()); + var input = (HivePrivilegeObject) inputs.getValue().getFirst(); + assertThat(input.getType()).isEqualTo(HivePrivilegeObjectType.TABLE_OR_VIEW); + assertThat(input.getObjectName()).isEqualTo("a_view"); + } + + @Test + @SuppressWarnings("unchecked") + void testFilterTables() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var visible = TableIdentifier.of(NAMESPACE, "visible"); + var hidden = TableIdentifier.of(NAMESPACE, "hidden"); + // Only "visible" survives the filter. + Mockito.when(hiveAuthorizer.filterListCmdObjects(anyList(), any())).thenAnswer(invocation -> { + List objects = invocation.getArgument(0); + return objects.stream().filter(o -> "visible".equals(o.getObjectName())).collect(Collectors.toList()); + }); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + Assertions.assertEquals(List.of(visible), icebergAuthorizer.filterTables(CATALOG_NAME, List.of(visible, hidden))); + + var objects = ArgumentCaptor.forClass(List.class); + verify(hiveAuthorizer).filterListCmdObjects(objects.capture(), any()); + var passed = (HivePrivilegeObject) objects.getValue().getFirst(); + assertThat(passed.getType()).isEqualTo(HivePrivilegeObjectType.TABLE_OR_VIEW); + assertThat(passed.getCatName()).isEqualTo(CATALOG_NAME); + assertThat(passed.getDbname()).isEqualTo(NAMESPACE.level(0)); + } + + @Test + void testFilterViews() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var visible = TableIdentifier.of(NAMESPACE, "visible_view"); + var hidden = TableIdentifier.of(NAMESPACE, "hidden_view"); + Mockito.when(hiveAuthorizer.filterListCmdObjects(anyList(), any())).thenAnswer(invocation -> { + List objects = invocation.getArgument(0); + return objects.stream().filter(o -> "visible_view".equals(o.getObjectName())).collect(Collectors.toList()); + }); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + Assertions.assertEquals(List.of(visible), icebergAuthorizer.filterViews(CATALOG_NAME, List.of(visible, hidden))); + } + + @Test + void testFilterNamespaces() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var visible = Namespace.of("visible_db"); + var hidden = Namespace.of("hidden_db"); + Mockito.when(hiveAuthorizer.filterListCmdObjects(anyList(), any())).thenAnswer(invocation -> { + List objects = invocation.getArgument(0); + return objects.stream().filter(o -> "visible_db".equals(o.getDbname())).collect(Collectors.toList()); + }); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + Assertions.assertEquals( + List.of(visible), icebergAuthorizer.filterNamespaces(CATALOG_NAME, List.of(visible, hidden))); + } + + @Test + void testFilterNamespacesSkipsMultiLevel() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + Mockito.when(hiveAuthorizer.filterListCmdObjects(anyList(), any())).thenAnswer(invocation -> invocation.getArgument(0)); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + var single = Namespace.of("db"); + var multi = Namespace.of("db", "nested"); + // Multi-level namespaces cannot map to a Hive database and are dropped rather than failing. + Assertions.assertEquals( + List.of(single), icebergAuthorizer.filterNamespaces(CATALOG_NAME, List.of(single, multi))); + } + + @Test + void testFilterWithoutAuthorizer() { + var icebergAuthorizer = new IcebergAuthorizer(() -> null); + var tables = List.of(TableIdentifier.of(NAMESPACE, TABLE_NAME)); + var namespaces = List.of(NAMESPACE); + // Permissive when no authorizer is configured: the full listing is returned unchanged. + Assertions.assertEquals(tables, icebergAuthorizer.filterTables(CATALOG_NAME, tables)); + Assertions.assertEquals(tables, icebergAuthorizer.filterViews(CATALOG_NAME, tables)); + Assertions.assertEquals(namespaces, icebergAuthorizer.filterNamespaces(CATALOG_NAME, namespaces)); + } + + @Test + void testAuthorizeLoadTableRejected() throws Exception { + var hiveAuthorizer = mock(HiveAuthorizer.class); + var failure = new HiveAccessControlException("access denied"); + doThrow(failure).when(hiveAuthorizer).checkPrivileges(any(), anyList(), anyList(), any()); + var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); + + var exception = Assertions.assertThrows(ForbiddenException.class, () -> + icebergAuthorizer.authorizeLoadTable(CATALOG_NAME, TableIdentifier.of(NAMESPACE, TABLE_NAME))); + Assertions.assertEquals("access denied", exception.getMessage()); + Assertions.assertSame(failure, exception.getCause()); + } + + @Test + void testAuthorizeReadWithoutAuthorizer() { + var icebergAuthorizer = new IcebergAuthorizer(() -> null); + // Permissive when no authorizer is configured. + icebergAuthorizer.authorizeLoadTable(CATALOG_NAME, TableIdentifier.of(NAMESPACE, TABLE_NAME)); + icebergAuthorizer.authorizeLoadView(CATALOG_NAME, TableIdentifier.of(NAMESPACE, "a_view")); + } + @Test void testTranslateAuthorizationPluginException() throws Exception { HiveAuthorizer hiveAuthorizer = mock(HiveAuthorizer.class); @@ -262,7 +419,7 @@ void testTranslateAuthorizationPluginException() throws Exception { var request = stageCreateRequest(LOCATION, null); var exception = Assertions.assertThrows(IllegalStateException.class, () -> icebergAuthorizer.validateStageCreateTable(CATALOG_NAME, NAMESPACE, Map.of(), request)); - Assertions.assertEquals("Failed to check privileges stage-create", exception.getMessage()); + Assertions.assertEquals("Failed to check privileges for CREATETABLE", exception.getMessage()); Assertions.assertSame(failure, exception.getCause()); } } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/MockHiveAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/MockHiveAuthorizer.java index 86b1cde870e3..5b84b16c0a5c 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/MockHiveAuthorizer.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/extension/MockHiveAuthorizer.java @@ -138,7 +138,12 @@ private boolean containsDeniedUri(HivePrivilegeObject priv) { @Override public List filterListCmdObjects(List listObjs, HiveAuthzContext context) { - return List.of(); + // Mirror checkPrivileges: the fully-denied user sees nothing, while read-only and regular users + // are allowed to read and therefore see the whole listing. + if (PERMISSION_TEST_USER.equals(authenticator.getUserName())) { + return List.of(); + } + return listObjs; } @Override From 1cf1f9b0337153ca29d31894bf1e85cbe6492cbb Mon Sep 17 00:00:00 2001 From: Henrib Date: Mon, 17 Aug 2026 15:06:57 +0200 Subject: [PATCH 17/20] HIVE-29817: align REST-catalog license headers with standardized asf.header HIVE-29755 restandardized the ASF header; bring the remaining metastore-rest-catalog files in line so checkstyle's header check passes. --- .../java/org/apache/iceberg/hive/MetadataLocator.java | 11 ++++++----- .../apache/iceberg/rest/HMSCachingCatalogMXBean.java | 11 ++++++----- .../org/apache/iceberg/rest/HMSCatalogServlet.java | 11 ++++++----- .../iceberg/rest/TestHMSCachingCatalogStats.java | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java index ca91cfee3dd7..f6acc758b894 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/hive/MetadataLocator.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.hive; diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java index d72c8e103ade..c9ed675f66e7 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalogMXBean.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java index ef4245b7be21..081151090ab5 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogServlet.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java index 3bd595907075..476134afa747 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestHMSCachingCatalogStats.java @@ -7,7 +7,7 @@ * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an From ce05d59c8c6fab55996cc3882a11317bb7fc4a4b Mon Sep 17 00:00:00 2001 From: Henrib Date: Mon, 17 Aug 2026 15:10:26 +0200 Subject: [PATCH 18/20] HIVE-29817: authorize cached reads in the catalog and memoize the authorizer Move the loadTable read check out of the adapter (where it double-authorized cold loads) into HMSCachingCatalog, enforced only on cache hits; misses reload through HMS and are authorized there. Drop the redundant loadView adapter check for the same reason (views are never cached). Memoize the per-thread authorizer toolkit so cache-hit checks avoid a full HiveConf clone and reflective lookups per call, extracted into newRequestAuthorizer, while refreshing identity per call for pooled threads. --- .../iceberg/rest/HMSCachingCatalog.java | 48 ++++++++-- .../iceberg/rest/HMSCatalogAdapter.java | 15 +-- .../iceberg/rest/HMSCatalogFactory.java | 26 +++-- .../iceberg/rest/IcebergAuthorizer.java | 95 +++++++++++++------ .../rest/TestHMSCachingCatalogCache.java | 50 +++++++++- .../iceberg/rest/TestIcebergAuthorizer.java | 29 ++---- 6 files changed, 185 insertions(+), 78 deletions(-) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index b12f6dee5183..f2ace1192d57 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; @@ -102,6 +103,10 @@ public HiveCatalog getCatalog() { // The underlying HiveCatalog that this caching catalog wraps. private final HiveCatalog hiveCatalog; + // Authorizes reads served from the cache. A cache hit never reaches Hive Metastore, so its read + // authorization cannot be deferred to HMS and is enforced here instead. May be null (no + // authorization), in which case cache hits are served without a check. + private final IcebergAuthorizer authorizer; // A helper that locates the metadata location for a given base table identifier. private final MetadataLocator metadataLocator; // An L2 table cache (Caffeine). @@ -128,12 +133,27 @@ public HiveCatalog getCatalog() { private ObjectName jmxObjectName; /** - * Creates a new caching catalog that wraps the given HiveCatalog. + * Creates a new caching catalog that wraps the given HiveCatalog, without cache-hit + * authorization. * @param catalog the underlying HiveCatalog * @param expirationMs the expiration time for the L2 cache, in milliseconds */ public HMSCachingCatalog(HiveCatalog catalog, long expirationMs) { + this(catalog, expirationMs, null); + } + + /** + * Creates a new caching catalog that wraps the given HiveCatalog. + * @param catalog the underlying HiveCatalog + * @param expirationMs the expiration time for the L2 cache, in milliseconds + * @param authorizer authorizes reads served from the cache; may be null for no authorization. + * A cache hit does not reach Hive Metastore, so read authorization for it is + * enforced here rather than deferred to HMS. Cache misses reload through the + * underlying {@link HiveCatalog} and are authorized by HMS as usual. + */ + public HMSCachingCatalog(HiveCatalog catalog, long expirationMs, IcebergAuthorizer authorizer) { this.hiveCatalog = catalog; + this.authorizer = authorizer; this.metadataLocator = new MetadataLocator(catalog); this.tableCache = Caffeine.newBuilder() .expireAfterAccess(expirationMs, TimeUnit.MILLISECONDS) @@ -446,6 +466,20 @@ public void invalidateView(TableIdentifier identifier) { hiveCatalog.invalidateView(identifier); } + /** + * Authorizes a read that is about to be served from the cache. A cache hit never reaches Hive + * Metastore, so its read authorization cannot be deferred to the HMS pre-event listener as a cache + * miss's can, and must be enforced here. No-op when no authorizer is configured. + * + * @param identifier the table (or metadata-table) identifier being read + * @throws org.apache.iceberg.exceptions.ForbiddenException if the current user may not read the table + */ + private void authorizeCachedRead(TableIdentifier identifier) { + if (authorizer != null) { + authorizer.authorizeLoadTable(hiveCatalog.name(), identifier); + } + } + @Override public Table loadTable(final TableIdentifier identifier) { final Table cachedTable = tableCache.getIfPresent(identifier); @@ -460,6 +494,7 @@ public Table loadTable(final TableIdentifier identifier) { LOG.debug("Table {} is in L1 cache, returning cached table", identifier); onL1CacheHit(identifier); onCacheHit(identifier); + authorizeCachedRead(identifier); return cachedTable; } else { l1Invalidate(identifier); @@ -483,6 +518,7 @@ public Table loadTable(final TableIdentifier identifier) { if (location.equals(cachedLocation)) { onCacheHit(identifier); l1MarkFresh(identifier, now); + authorizeCachedRead(identifier); return cachedTable; } else { LOG.debug("Invalidate table {}, cached {} != actual {}", identifier, cachedLocation, location); diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java index 6ef341a8e522..c6380ec53c5b 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogAdapter.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; @@ -323,7 +324,6 @@ private RESTResponse tableExists(Map vars) { private LoadTableResponse loadTable(Map vars) { TableIdentifier ident = identFromPathVars(vars); - icebergAuthorizer.authorizeLoadTable(catalogName, ident); return castResponse(LoadTableResponse.class, CatalogHandlers.loadTable(catalog, ident)); } @@ -388,7 +388,8 @@ private RESTResponse viewExists(Map vars) { private LoadViewResponse loadView(Map vars) { TableIdentifier ident = viewIdentFromPathVars(vars); - icebergAuthorizer.authorizeLoadView(catalogName, ident); + // Read authorization is enforced by HMS: views are not cached, so loadView always reaches the + // metastore, whose pre-event listener authorizes the read. Checking here would double-authorize. return castResponse(LoadViewResponse.class, CatalogHandlers.loadView(asViewCatalog, ident)); } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java index a6d0efc5c602..805ad733be93 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCatalogFactory.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; @@ -69,12 +70,15 @@ public String getPath() { /** * Creates the catalog instance. + * @param authorizer authorizes reads served from the table cache; the caching catalog enforces it + * on cache hits, which never reach HMS. Ignored when caching is disabled, as an + * uncached load reaches HMS and is authorized there. * @return the catalog */ - private Catalog createCatalog() { + private Catalog createCatalog(IcebergAuthorizer authorizer) { final HiveCatalog hiveCatalog = createHiveCatalog(configuration); long expiry = MetastoreConf.getLongVar(configuration, MetastoreConf.ConfVars.ICEBERG_CATALOG_CACHE_EXPIRY); - return expiry > 0 ? new HMSCachingCatalog(hiveCatalog, expiry) : hiveCatalog; + return expiry > 0 ? new HMSCachingCatalog(hiveCatalog, expiry, authorizer) : hiveCatalog; } /** @@ -123,13 +127,12 @@ public static HiveCatalog createHiveCatalog(Configuration configuration) { * @param catalog the Iceberg catalog * @return the servlet */ - private HttpServlet createServlet(Catalog catalog) { + private HttpServlet createServlet(Catalog catalog, IcebergAuthorizer icebergAuthorizer) { String authType = MetastoreConf.getVar(configuration, ConfVars.CATALOG_SERVLET_AUTH); // Iceberg REST client uses "catalog" by default List scopes = Collections.singletonList("catalog"); ServletSecurity security = new ServletSecurity(AuthType.fromString(authType), configuration, req -> scopes); String catalogName = MetastoreConf.getVar(configuration, ConfVars.CATALOG_DEFAULT); - IcebergAuthorizer icebergAuthorizer = new IcebergAuthorizer(configuration); List reporters = createReporters(); var adapter = new HMSCatalogAdapter(catalogName, catalog, icebergAuthorizer, reporters); return security.proxy(new HMSCatalogServlet(adapter)); @@ -153,7 +156,10 @@ private List createReporters() { */ private HttpServlet createServlet() { if (port >= 0 && path != null && !path.isEmpty()) { - return createServlet(createCatalog()); + // Build the authorizer first so it can be shared: the caching catalog uses it to authorize + // cache hits, and the adapter uses it for list filtering and stage-create. + IcebergAuthorizer icebergAuthorizer = new IcebergAuthorizer(configuration); + return createServlet(createCatalog(icebergAuthorizer), icebergAuthorizer); } return null; } diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java index 296e49e795af..c01027dc57de 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/IcebergAuthorizer.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; @@ -37,8 +38,10 @@ import org.apache.hadoop.hive.metastore.conf.MetastoreConf; import org.apache.hadoop.hive.ql.metadata.HiveException; import org.apache.hadoop.hive.ql.metadata.HiveUtils; +import org.apache.hadoop.hive.ql.security.HiveAuthenticationProvider; import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAccessControlException; import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthorizer; +import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthorizerFactory; import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthzContext; import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthzPluginException; import org.apache.hadoop.hive.ql.security.authorization.plugin.HiveAuthzSessionContext; @@ -81,27 +84,69 @@ class IcebergAuthorizer { .formatted(Arrays.toString(classes))); } - this.authorizerSupplier = () -> { + // Building a HiveAuthorizer is dominated by identity-independent work: HiveConf.cloneConf (a full + // Configuration deep-copy) and the reflective factory/authenticator lookups. Since read + // authorization now runs on every table cache hit, do that work once per thread and keep only the + // identity-sensitive steps per call. This mirrors HiveMetaStoreAuthorizer, which caches the + // authenticator in a ThreadLocal and refreshes it via setConf on every event; we additionally cache + // the cloned conf and factory (which it rebuilds per event) since they carry no identity. + // + // Per-thread caching is required for correctness, not just speed: the default authenticator + // (HadoopDefaultAuthenticator) resolves and caches the user name at setConf time from the current + // UserGroupInformation. Jetty worker threads are pooled and reused across end users, so the toolkit + // must be thread-confined and setConf must be re-run each call to bind the current request's identity. + // A single shared authorizer would pin authorization to whichever user built it first. + final ThreadLocal toolkit = ThreadLocal.withInitial(() -> { try { final var hiveConf = HiveConf.cloneConf(conf); final var authorizerFactory = HiveUtils.getAuthorizerFactory(hiveConf, HiveConf.ConfVars.HIVE_AUTHORIZATION_MANAGER); - final var authenticator = HiveUtils.getAuthenticator(hiveConf, HiveConf.ConfVars.HIVE_METASTORE_AUTHENTICATOR_MANAGER); - authenticator.setConf(hiveConf); - - final var authzContextBuilder = new HiveAuthzSessionContext.Builder(); - authzContextBuilder.setClientType(HiveAuthzSessionContext.CLIENT_TYPE.HIVEMETASTORE); - authzContextBuilder.setSessionString("IcebergRESTCatalog"); - return authorizerFactory.createHiveAuthorizer( - new HiveMetastoreClientFactoryImpl(hiveConf), hiveConf, authenticator, authzContextBuilder.build()); + return new AuthorizerToolkit(hiveConf, authorizerFactory, authenticator); } catch (HiveException e) { throw new IllegalStateException("Failed to initialize Hive authorizer for Iceberg REST Catalog", e); } - }; + }); + + this.authorizerSupplier = () -> newRequestAuthorizer(toolkit.get()); } + /** + * Builds a request-scoped {@link HiveAuthorizer} from the calling thread's cached {@link + * AuthorizerToolkit}. Rebinds the authenticator to the current request's UGI via {@code setConf} + * and rebuilds the authorizer on every call (rather than caching it), matching {@link + * HiveMetaStoreAuthorizer#createHiveMetaStoreAuthorizer()}, so no authorizer implementation can + * retain a stale identity across a pooled thread's successive requests. + * + * @param kit the calling thread's identity-independent building blocks + * @return an authorizer bound to the current request's identity + * @throws IllegalStateException if the authorization plugin fails to initialize + */ + private static HiveAuthorizer newRequestAuthorizer(AuthorizerToolkit kit) { + try { + kit.authenticator.setConf(kit.hiveConf); + final var authzContextBuilder = new HiveAuthzSessionContext.Builder(); + authzContextBuilder.setClientType(HiveAuthzSessionContext.CLIENT_TYPE.HIVEMETASTORE); + authzContextBuilder.setSessionString("IcebergRESTCatalog"); + return kit.authorizerFactory.createHiveAuthorizer( + new HiveMetastoreClientFactoryImpl(kit.hiveConf), kit.hiveConf, kit.authenticator, + authzContextBuilder.build()); + } catch (HiveException e) { + throw new IllegalStateException("Failed to initialize Hive authorizer for Iceberg REST Catalog", e); + } + } + + /** + * Per-thread, identity-independent building blocks for a {@link HiveAuthorizer}. Cached in a + * {@link ThreadLocal} so the expensive {@link HiveConf} clone and reflective factory/authenticator + * lookups run once per thread; the identity-sensitive {@code setConf}/{@code createHiveAuthorizer} + * steps still run on every authorization call. + */ + private record AuthorizerToolkit(HiveConf hiveConf, + HiveAuthorizerFactory authorizerFactory, + HiveAuthenticationProvider authenticator) {} + @VisibleForTesting IcebergAuthorizer(Supplier authorizerSupplier) { this.authorizerSupplier = authorizerSupplier; @@ -159,9 +204,10 @@ void validateStageCreateTable(String catalogName, Namespace namespace, Map hiveAuthorizer)); + + // Cold miss reloads through HMS (authorized there), so the cache authorizer is NOT consulted. + Table firstLoad = authzCatalog.loadTable(TABLE_ID); + assertThat(firstLoad).isNotNull(); + verify(hiveAuthorizer, never()).checkPrivileges(any(), anyList(), anyList(), any()); + + // The next load is served from the L1 cache without reaching HMS, so it must be authorized here + // — the denying authorizer turns the hit into a ForbiddenException. + assertThatThrownBy(() -> authzCatalog.loadTable(TABLE_ID)).isInstanceOf(ForbiddenException.class); + verify(hiveAuthorizer, times(1)).checkPrivileges(any(), anyList(), anyList(), any()); + } + + @Test + void testCacheHitAllowedByAuthorizer() { + Table created = hiveCatalog.createTable(TABLE_ID, SCHEMA); + + // A permissive authorizer: checkPrivileges is a no-op mock, so it never throws. + HiveAuthorizer hiveAuthorizer = mock(HiveAuthorizer.class); + HMSCachingCatalog authzCatalog = + new HMSCachingCatalog(hiveCatalog, CACHE_EXPIRY_MS, new IcebergAuthorizer(() -> hiveAuthorizer)); + + Table first = authzCatalog.loadTable(TABLE_ID); + Table second = authzCatalog.loadTable(TABLE_ID); + assertThat(first.location()).isEqualTo(created.location()); + // The second load is an authorized cache hit and returns the identical instance. + assertThat(second).isSameAs(first); + } } diff --git a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java index bf51c37d8f27..b10bcca6d8f0 100644 --- a/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java +++ b/standalone-metastore/metastore-rest-catalog/src/test/java/org/apache/iceberg/rest/TestIcebergAuthorizer.java @@ -9,11 +9,12 @@ * * http://www.apache.org/licenses/LICENSE-2.0 * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ package org.apache.iceberg.rest; @@ -295,23 +296,6 @@ void testAuthorizeLoadTableNormalizesMetadataTable() throws Exception { assertThat(input.getObjectName()).isEqualTo(TABLE_NAME); } - @Test - @SuppressWarnings("unchecked") - void testAuthorizeLoadView() throws Exception { - var hiveAuthorizer = mock(HiveAuthorizer.class); - var icebergAuthorizer = new IcebergAuthorizer(() -> hiveAuthorizer); - - icebergAuthorizer.authorizeLoadView(CATALOG_NAME, TableIdentifier.of(NAMESPACE, "a_view")); - - var operation = ArgumentCaptor.forClass(HiveOperationType.class); - var inputs = ArgumentCaptor.forClass(List.class); - verify(hiveAuthorizer).checkPrivileges(operation.capture(), inputs.capture(), anyList(), any()); - Assertions.assertEquals(HiveOperationType.QUERY, operation.getValue()); - var input = (HivePrivilegeObject) inputs.getValue().getFirst(); - assertThat(input.getType()).isEqualTo(HivePrivilegeObjectType.TABLE_OR_VIEW); - assertThat(input.getObjectName()).isEqualTo("a_view"); - } - @Test @SuppressWarnings("unchecked") void testFilterTables() throws Exception { @@ -406,7 +390,6 @@ void testAuthorizeReadWithoutAuthorizer() { var icebergAuthorizer = new IcebergAuthorizer(() -> null); // Permissive when no authorizer is configured. icebergAuthorizer.authorizeLoadTable(CATALOG_NAME, TableIdentifier.of(NAMESPACE, TABLE_NAME)); - icebergAuthorizer.authorizeLoadView(CATALOG_NAME, TableIdentifier.of(NAMESPACE, "a_view")); } @Test From bcbb8d6142fcfffb7790b3e731630be8aac54b6d Mon Sep 17 00:00:00 2001 From: Henrib Date: Tue, 18 Aug 2026 15:16:38 +0200 Subject: [PATCH 19/20] HIVE-29817 : fix javadoc; --- .../main/java/org/apache/iceberg/rest/HMSCachingCatalog.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index f2ace1192d57..079f7972ec0b 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -96,6 +96,10 @@ public final class HMSCachingCatalog implements Catalog, SupportsNamespaces, ViewCatalog, HMSCachingCatalogMXBean, Closeable { private static final Logger LOG = LoggerFactory.getLogger(HMSCachingCatalog.class); + /** + * Returns the underlying {@link HiveCatalog} that this caching catalog wraps. + * This is intended for testing purposes only; production code should not rely on the underlying catalog. + */ @TestOnly public HiveCatalog getCatalog() { return hiveCatalog; From 6f67044198c3a3644edda09ae4c3d5ecdf1bb417 Mon Sep 17 00:00:00 2001 From: Henrib Date: Wed, 19 Aug 2026 08:10:27 +0200 Subject: [PATCH 20/20] HIVE-29817 : fix javadoc; --- .../src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java | 1 + 1 file changed, 1 insertion(+) diff --git a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java index 079f7972ec0b..7fa509c1ce39 100644 --- a/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java +++ b/standalone-metastore/metastore-rest-catalog/src/main/java/org/apache/iceberg/rest/HMSCachingCatalog.java @@ -99,6 +99,7 @@ public final class HMSCachingCatalog /** * Returns the underlying {@link HiveCatalog} that this caching catalog wraps. * This is intended for testing purposes only; production code should not rely on the underlying catalog. + * @return the underlying HiveCatalog */ @TestOnly public HiveCatalog getCatalog() {