This is an automated email from the ASF dual-hosted git repository.
bharos pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new dad9a08dcd [#11584] feat(iceberg-rest): Gate config endpoints by
backend capabilities (#11634)
dad9a08dcd is described below
commit dad9a08dcdf397ed9558e4c36d57bdd49b75f3bf
Author: Akshay Thorat <[email protected]>
AuthorDate: Thu Sep 3 11:19:25 2026 -0700
[#11584] feat(iceberg-rest): Gate config endpoints by backend capabilities
(#11634)
### What changes were proposed in this pull request?
Derive the endpoint set advertised by `/v1/config` from the backing
catalog's capabilities rather than a hardcoded static list.
`V1_SUBMIT_TABLE_SCAN_PLAN` is gated behind
`CatalogWrapperForREST.supportsScanPlanOperations()`:
- `CatalogWrapperForREST` returns `true`. Gravitino plans scans locally
on loaded table metadata, which works for every backend it handles
directly (Hive, JDBC, Memory, Custom).
- `FederatedCatalogWrapper` overrides it to report what the remote
catalog advertises in its own `/v1/config`, since it delegates
`planTableScan` upstream rather than planning locally. The result is
cached for the wrapper's lifetime, so the remote is queried at most once
instead of on every local `/v1/config` call.
A remote that omits `endpoints` is treated as not supporting scan
planning, matching the Iceberg client's fallback to an endpoint set that
predates scan planning. A failed lookup is not cached and does not
advertise the endpoint, so it can resolve once the remote recovers.
Also extracts the auth manager / HTTP client / auth session lifecycle
shared by the federated credential, scan-plan and config calls into
`callRemoteCatalog`, rather than adding a third copy of that
boilerplate.
### Why are the changes needed?
`IcebergConfigOperations` advertises a hardcoded `DEFAULT_ENDPOINTS`
list (including `V1_SUBMIT_TABLE_SCAN_PLAN`) for every catalog,
regardless of whether the configured backend actually supports those
operations. A client that trusts the advertised `endpoints` may call an
operation the backend cannot serve, producing confusing runtime errors.
Fix: #11584
### Does this PR introduce _any_ user-facing change?
A federated (REST backend) catalog advertises the scan plan endpoint
only when the remote catalog it federates advertises it. Clients that
rely on the advertised endpoints will see the operations their catalog
can actually serve. Non-REST backends are unaffected.
### How was this patch tested?
- `TestCatalogWrapperForREST` gains coverage against an embedded
`/v1/config` server: support reported when the remote advertises the
endpoint, not reported when it omits the endpoint or sends no
`endpoints` at all, the remote queried only once, `warehouse` forwarded
and URL-encoded, and a failed lookup not cached.
- `TestIcebergConfigEndpointGating` and `TestIcebergConfig` cover the
`/v1/config` wiring in both directions.
- `:iceberg:iceberg-rest-server:test` is green (404 tests, 0 failures).
---
.../iceberg/service/CatalogWrapperForREST.java | 14 ++
.../iceberg/service/FederatedCatalogWrapper.java | 216 +++++++++++++--------
.../service/rest/IcebergConfigOperations.java | 31 +--
.../iceberg/service/TestCatalogWrapperForREST.java | 195 +++++++++++++++++++
.../iceberg/service/rest/IcebergRestTestUtil.java | 21 +-
.../iceberg/service/rest/TestIcebergConfig.java | 23 +++
.../rest/TestIcebergConfigEndpointGating.java | 124 ++++++++++++
7 files changed, 530 insertions(+), 94 deletions(-)
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
index 1af51f5295..c58547e4fc 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/CatalogWrapperForREST.java
@@ -181,6 +181,20 @@ public class CatalogWrapperForREST extends
IcebergCatalogWrapper {
}
}
+ /**
+ * Whether this catalog wrapper supports server-side scan planning.
+ *
+ * <p>Gravitino plans scans locally on top of loaded table metadata, which
works for every backend
+ * this class handles directly (Hive, JDBC, Memory, Custom). {@link
FederatedCatalogWrapper}
+ * delegates planning to the remote catalog instead, so it overrides this to
report what that
+ * catalog actually advertises.
+ *
+ * @return {@code true} if the scan-plan endpoint should be advertised
+ */
+ public boolean supportsScanPlanOperations() {
+ return true;
+ }
+
@Override
public void close() throws Exception {
try {
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
index b4128ff724..698ae3220c 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/FederatedCatalogWrapper.java
@@ -30,6 +30,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.gravitino.credential.CredentialPrivilege;
import org.apache.gravitino.credential.CredentialPropertyUtils;
@@ -56,6 +57,7 @@ import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.inmemory.InMemoryFileIO;
import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.rest.CatalogHandlers;
+import org.apache.iceberg.rest.Endpoint;
import org.apache.iceberg.rest.ErrorHandlers;
import org.apache.iceberg.rest.HTTPClient;
import org.apache.iceberg.rest.ParserContext;
@@ -71,6 +73,7 @@ import org.apache.iceberg.rest.requests.CreateTableRequest;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.ConfigResponse;
import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.apache.iceberg.rest.responses.PlanTableScanResponse;
@@ -94,6 +97,13 @@ public class FederatedCatalogWrapper extends
CatalogWrapperForREST {
private static final String FORMAT_VERSION = "format-version";
private static final Schema EMPTY_SCHEMA = new Schema();
+ /**
+ * Caches whether the remote catalog advertises the scan-plan endpoint. Only
successful lookups
+ * are cached, so a failure to reach the remote can be retried. Races just
repeat an idempotent
+ * lookup.
+ */
+ private volatile Boolean remoteSupportsScanPlan;
+
/**
* Creates a federated wrapper.
*
@@ -188,12 +198,111 @@ public class FederatedCatalogWrapper extends
CatalogWrapperForREST {
catalogCredentialManager.catalogName(), tableIdentifier, response);
}
+ /**
+ * Reports whether the remote catalog advertises the scan-plan endpoint,
since {@link
+ * #planTableScan} delegates planning to it rather than planning locally.
+ *
+ * <p>The answer comes from the remote catalog's own {@code /v1/config}
response and is cached for
+ * the lifetime of this wrapper, so the remote is queried at most once
rather than on every local
+ * {@code /v1/config} call.
+ *
+ * <p>A remote that omits {@code endpoints} is treated as not supporting
scan planning. That
+ * matches the Iceberg client, which falls back to a default endpoint set
that predates scan
+ * planning when the field is absent.
+ *
+ * <p>If the remote cannot be reached the result is not cached and the
endpoint is not advertised,
+ * so a later call can still resolve it once the remote recovers. Not
advertising is the safe
+ * direction here: the endpoint would fail anyway while the remote is
unreachable.
+ *
+ * @return {@code true} if the remote catalog advertises {@code
V1_SUBMIT_TABLE_SCAN_PLAN}.
+ */
+ @Override
+ public boolean supportsScanPlanOperations() {
+ Boolean cached = remoteSupportsScanPlan;
+ if (cached != null) {
+ return cached;
+ }
+
+ try {
+ boolean supported =
+
fetchRemoteConfig().endpoints().contains(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN);
+ remoteSupportsScanPlan = supported;
+ return supported;
+ } catch (Exception e) {
+ LOG.warn(
+ "Failed to read the endpoints advertised by the remote catalog of
{}; not advertising the"
+ + " scan plan endpoint",
+ catalogCredentialManager.catalogName(),
+ e);
+ return false;
+ }
+ }
+
+ /**
+ * Fetches the remote catalog's {@code /v1/config} response.
+ *
+ * <p>The {@code warehouse} query parameter is forwarded when configured, so
a remote serving
+ * several warehouses returns the endpoint set for the one this catalog
federates.
+ *
+ * <p>{@code RESTCatalog} already fetched this at init but keeps the
endpoint set private, so it
+ * has to be re-fetched here.
+ *
+ * @return the remote catalog's config response.
+ */
+ private ConfigResponse fetchRemoteConfig() {
+ RESTCatalog restCatalog = (RESTCatalog) getCatalog();
+ String warehouse =
restCatalog.properties().get(CatalogProperties.WAREHOUSE_LOCATION);
+ Map<String, String> queryParams =
+ warehouse == null || warehouse.isEmpty()
+ ? Collections.emptyMap()
+ : ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, warehouse);
+
+ return callRemoteCatalog(
+ restCatalog,
+ "reading the remote catalog config",
+ client ->
+ client.get(
+ ResourcePaths.config(),
+ queryParams,
+ ConfigResponse.class,
+ Collections.emptyMap(),
+ ErrorHandlers.configErrorHandler()));
+ }
+
private static LoadCredentialsResponse getRESTTableCredentials(
RESTCatalog restCatalog, TableIdentifier identifier) {
Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
String credentialsPath =
ResourcePaths.forCatalogProperties(properties).table(identifier) +
"/credentials";
+ return callRemoteCatalog(
+ restCatalog,
+ String.format("loading credentials for table: %s", identifier),
+ client ->
+ client.get(
+ credentialsPath,
+ LoadCredentialsResponse.class,
+ Collections.emptyMap(),
+ ErrorHandlers.tableErrorHandler()));
+ }
+
+ /**
+ * Runs an action against the remote REST catalog through a short-lived
authenticated client.
+ *
+ * <p>Centralizes the auth manager, HTTP client and auth session lifecycle
shared by the federated
+ * credential, scan-plan and config requests. Resources are closed in
reverse order of creation,
+ * and a close failure on one does not prevent the others from being closed.
+ *
+ * @param restCatalog the underlying REST catalog whose properties supply
the URI and auth config.
+ * @param description what the action is doing, used in close-failure log
messages.
+ * @param action invoked with a client bound to an authenticated session.
+ * @param <T> the action's result type.
+ * @return the action's result.
+ */
+ private static <T> T callRemoteCatalog(
+ RESTCatalog restCatalog, String description, Function<RESTClient, T>
action) {
+ Map<String, String> properties = Maps.newHashMap(restCatalog.properties());
+
AuthManager authManager = null;
RESTClient client = null;
AuthSession authSession = null;
@@ -205,40 +314,24 @@ public class FederatedCatalogWrapper extends
CatalogWrapperForREST {
.withHeaders(RESTUtil.configHeaders(properties))
.build();
authSession = authManager.catalogSession(client, properties);
- return client
- .withAuthSession(authSession)
- .get(
- credentialsPath,
- LoadCredentialsResponse.class,
- Collections.emptyMap(),
- ErrorHandlers.tableErrorHandler());
+ return action.apply(client.withAuthSession(authSession));
} finally {
- if (authSession != null) {
- try {
- authSession.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close auth session when loading credentials for
table: {}", identifier, e);
- }
- }
+ closeQuietly(authSession, "auth session", description);
+ closeQuietly(client, "REST client", description);
+ closeQuietly(authManager, "auth manager", description);
+ }
+ }
- if (client != null) {
- try {
- client.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close REST client when loading credentials for table:
{}", identifier, e);
- }
- }
+ private static void closeQuietly(
+ AutoCloseable closeable, String resourceName, String description) {
+ if (closeable == null) {
+ return;
+ }
- if (authManager != null) {
- try {
- authManager.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close auth manager when loading credentials for
table: {}", identifier, e);
- }
- }
+ try {
+ closeable.close();
+ } catch (Exception e) {
+ LOG.warn("Failed to close {} when {}", resourceName, description, e);
}
}
@@ -280,55 +373,18 @@ public class FederatedCatalogWrapper extends
CatalogWrapperForREST {
.add("caseSensitive", scanRequest.caseSensitive())
.build();
- AuthManager authManager = null;
- RESTClient client = null;
- AuthSession authSession = null;
- try {
- authManager = AuthManagers.loadAuthManager(restCatalog.name(),
properties);
- client =
- HTTPClient.builder(properties)
- .uri(properties.get(CatalogProperties.URI))
- .withHeaders(RESTUtil.configHeaders(properties))
- .build();
- authSession = authManager.catalogSession(client, properties);
- return client
- .withAuthSession(authSession)
- .post(
- planPath,
- scanRequest,
- PlanTableScanResponse.class,
- headers,
- ErrorHandlers.planErrorHandler(),
- ignored -> {},
- parserContext);
- } finally {
- if (authSession != null) {
- try {
- authSession.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close auth session when planning table scan for
table: {}", identifier, e);
- }
- }
-
- if (client != null) {
- try {
- client.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close REST client when planning table scan for table:
{}", identifier, e);
- }
- }
-
- if (authManager != null) {
- try {
- authManager.close();
- } catch (Exception e) {
- LOG.warn(
- "Failed to close auth manager when planning table scan for
table: {}", identifier, e);
- }
- }
- }
+ return callRemoteCatalog(
+ restCatalog,
+ String.format("planning table scan for table: %s", identifier),
+ client ->
+ client.post(
+ planPath,
+ scanRequest,
+ PlanTableScanResponse.class,
+ headers,
+ ErrorHandlers.planErrorHandler(),
+ ignored -> {},
+ parserContext));
}
/**
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergConfigOperations.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergConfigOperations.java
index 0766b2f348..59e0f9fdde 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergConfigOperations.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/rest/IcebergConfigOperations.java
@@ -75,9 +75,11 @@ public class IcebergConfigOperations {
.add(Endpoint.V1_REGISTER_TABLE)
.add(Endpoint.V1_REPORT_METRICS)
.add(Endpoint.V1_TABLE_CREDENTIALS)
- .add(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN)
.build();
+ private static final List<Endpoint> SCAN_PLAN_ENDPOINTS =
+ ImmutableList.of(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN);
+
private static final List<Endpoint> DEFAULT_VIEW_ENDPOINTS =
ImmutableList.<Endpoint>builder()
.add(Endpoint.V1_LIST_VIEWS)
@@ -101,21 +103,29 @@ public class IcebergConfigOperations {
@ResponseMetered(name = "config", absolute = true)
public Response getConfig(@DefaultValue("") @QueryParam("warehouse") String
warehouse) {
String catalogName = getCatalogName(warehouse);
- boolean supportsView = supportsViewOperations(catalogName);
+ CatalogWrapperForREST catalogWrapper = getCatalogWrapper(catalogName);
+ boolean supportsView = catalogWrapper.supportsViewOperations();
+ boolean supportsScanPlan = catalogWrapper.supportsScanPlanOperations();
ConfigResponse.Builder builder = ConfigResponse.builder();
-
builder.withDefaults(getDefaultConfig(catalogName)).withEndpoints(getEndpoints(supportsView));
+ builder
+ .withDefaults(getDefaultConfig(catalogName))
+ .withEndpoints(getEndpoints(supportsView, supportsScanPlan));
if (StringUtils.isNotBlank(warehouse)) {
builder.withDefault("prefix", warehouse);
}
return IcebergRESTUtils.ok(builder.build());
}
- private List<Endpoint> getEndpoints(boolean supportsViewOperations) {
- if (!supportsViewOperations) {
- return DEFAULT_ENDPOINTS;
+ private List<Endpoint> getEndpoints(
+ boolean supportsViewOperations, boolean supportsScanPlanOperations) {
+ Stream<Endpoint> endpoints = DEFAULT_ENDPOINTS.stream();
+ if (supportsScanPlanOperations) {
+ endpoints = Stream.concat(endpoints, SCAN_PLAN_ENDPOINTS.stream());
+ }
+ if (supportsViewOperations) {
+ endpoints = Stream.concat(endpoints, DEFAULT_VIEW_ENDPOINTS.stream());
}
- return Stream.concat(DEFAULT_ENDPOINTS.stream(),
DEFAULT_VIEW_ENDPOINTS.stream())
- .collect(Collectors.toList());
+ return endpoints.collect(Collectors.toList());
}
private Map<String, String> getCatalogConfig(String catalogName) {
@@ -133,11 +143,6 @@ public class IcebergConfigOperations {
}
}
- private boolean supportsViewOperations(String catalogName) {
- CatalogWrapperForREST catalogWrapperForREST =
getCatalogWrapper(catalogName);
- return catalogWrapperForREST.supportsViewOperations();
- }
-
private CatalogWrapperForREST getCatalogWrapper(String catalogName) {
return catalogWrapperManager.getCatalogWrapper(catalogName);
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
index 713091a9e4..50a3f684d1 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/TestCatalogWrapperForREST.java
@@ -36,6 +36,7 @@ import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -73,6 +74,7 @@ import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.io.ResolvingFileIO;
import org.apache.iceberg.io.StorageCredential;
import org.apache.iceberg.io.SupportsStorageCredentials;
+import org.apache.iceberg.rest.Endpoint;
import org.apache.iceberg.rest.PlanStatus;
import org.apache.iceberg.rest.RESTCatalog;
import org.apache.iceberg.rest.auth.AuthProperties;
@@ -82,6 +84,8 @@ import
org.apache.iceberg.rest.requests.ImmutableRegisterTableRequest;
import org.apache.iceberg.rest.requests.PlanTableScanRequest;
import org.apache.iceberg.rest.requests.RegisterTableRequest;
import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.responses.ConfigResponse;
+import org.apache.iceberg.rest.responses.ConfigResponseParser;
import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.apache.iceberg.rest.responses.PlanTableScanResponse;
@@ -1189,6 +1193,197 @@ public class TestCatalogWrapperForREST {
new MetadataUpdate.AssignUUID(UUID.randomUUID().toString())));
}
+ @Test
+ void testScanPlanSupportedForNonRESTBackend() {
+ IcebergConfig config =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ IcebergConstants.WAREHOUSE,
+ "/tmp/warehouse"));
+
+ // Gravitino plans the scan locally for non-REST backends, so it always
supports the endpoint.
+ CatalogWrapperForREST wrapper = new CatalogWrapperForREST("local-catalog",
config);
+ Assertions.assertTrue(wrapper.supportsScanPlanOperations());
+ }
+
+ @Test
+ void testScanPlanSupportedWhenRemoteAdvertisesEndpoint() throws Exception {
+ ConfigResponse remoteConfig =
+ ConfigResponse.builder()
+ .withEndpoints(
+ Arrays.asList(Endpoint.V1_LOAD_TABLE,
Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN))
+ .build();
+
+ withRemoteConfigServer(
+ remoteConfig,
+ (wrapper, requests) ->
Assertions.assertTrue(wrapper.supportsScanPlanOperations()));
+ }
+
+ @Test
+ void testScanPlanUnsupportedWhenRemoteOmitsEndpoint() throws Exception {
+ ConfigResponse remoteConfig =
+ ConfigResponse.builder()
+ .withEndpoints(Arrays.asList(Endpoint.V1_LOAD_TABLE,
Endpoint.V1_LIST_TABLES))
+ .build();
+
+ withRemoteConfigServer(
+ remoteConfig,
+ (wrapper, requests) ->
Assertions.assertFalse(wrapper.supportsScanPlanOperations()));
+ }
+
+ @Test
+ void testScanPlanUnsupportedWhenRemoteAdvertisesNoEndpoints() throws
Exception {
+ // A remote that omits "endpoints" is treated the same way the Iceberg
client treats it: as a
+ // catalog whose endpoint set predates scan planning.
+ withRemoteConfigServer(
+ ConfigResponse.builder().build(),
+ (wrapper, requests) ->
Assertions.assertFalse(wrapper.supportsScanPlanOperations()));
+ }
+
+ @Test
+ void testScanPlanSupportQueriesRemoteOnlyOnce() throws Exception {
+ ConfigResponse remoteConfig =
+ ConfigResponse.builder()
+
.withEndpoints(Collections.singletonList(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN))
+ .build();
+
+ withRemoteConfigServer(
+ remoteConfig,
+ (wrapper, requests) -> {
+ Assertions.assertTrue(wrapper.supportsScanPlanOperations());
+ Assertions.assertTrue(wrapper.supportsScanPlanOperations());
+ Assertions.assertTrue(wrapper.supportsScanPlanOperations());
+ Assertions.assertEquals(
+ 1, requests.size(), "The remote config should be fetched once
and then cached");
+ });
+ }
+
+ @Test
+ void testScanPlanSupportForwardsWarehouseToRemote() throws Exception {
+ ConfigResponse remoteConfig =
+ ConfigResponse.builder()
+
.withEndpoints(Collections.singletonList(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN))
+ .build();
+
+ withRemoteConfigServer(
+ remoteConfig,
+ ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION,
"s3://remote/warehouse"),
+ (wrapper, requests) -> {
+ Assertions.assertTrue(wrapper.supportsScanPlanOperations());
+ Assertions.assertEquals(1, requests.size());
+ Assertions.assertEquals("/v1/config", requests.get(0).path);
+ Assertions.assertEquals(
+ "warehouse=s3%3A%2F%2Fremote%2Fwarehouse",
requests.get(0).rawQuery);
+ });
+ }
+
+ @Test
+ void testScanPlanSupportLookupFailureIsNotCached() throws Exception {
+ List<RecordedRequest> requests = Collections.synchronizedList(new
ArrayList<>());
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext(
+ "/",
+ exchange -> {
+ requests.add(
+ new RecordedRequest(
+ exchange.getRequestURI().getPath(),
exchange.getRequestURI().getRawQuery()));
+ // 400 rather than a retryable status, so each lookup is exactly one
request and the
+ // assertion below counts lookups rather than the REST client's
retry attempts.
+ exchange.sendResponseHeaders(400, -1);
+ exchange.close();
+ });
+ server.start();
+ try {
+ CatalogWrapperForREST wrapper = federatedWrapperFor(server,
Collections.emptyMap());
+
+ // A failed lookup must not be cached, so a later call can resolve it
once the remote is back.
+ Assertions.assertFalse(wrapper.supportsScanPlanOperations());
+ Assertions.assertFalse(wrapper.supportsScanPlanOperations());
+ Assertions.assertEquals(2, requests.size());
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ private void withRemoteConfigServer(ConfigResponse remoteConfig,
RemoteConfigAssertion assertion)
+ throws Exception {
+ withRemoteConfigServer(remoteConfig, Collections.emptyMap(), assertion);
+ }
+
+ /**
+ * Serves {@code remoteConfig} from an embedded {@code /v1/config} endpoint
and hands the test a
+ * federated wrapper pointed at it, along with the requests the wrapper made.
+ */
+ private void withRemoteConfigServer(
+ ConfigResponse remoteConfig,
+ Map<String, String> extraCatalogProperties,
+ RemoteConfigAssertion assertion)
+ throws Exception {
+ String responseJson = ConfigResponseParser.toJson(remoteConfig);
+ List<RecordedRequest> requests = Collections.synchronizedList(new
ArrayList<>());
+
+ HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
+ server.createContext(
+ "/",
+ exchange -> {
+ requests.add(
+ new RecordedRequest(
+ exchange.getRequestURI().getPath(),
exchange.getRequestURI().getRawQuery()));
+ byte[] body = responseJson.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Content-Type",
"application/json");
+ exchange.sendResponseHeaders(200, body.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(body);
+ }
+ });
+ server.start();
+ try {
+ assertion.accept(federatedWrapperFor(server, extraCatalogProperties),
requests);
+ } finally {
+ server.stop(0);
+ }
+ }
+
+ private CatalogWrapperForREST federatedWrapperFor(
+ HttpServer server, Map<String, String> extraCatalogProperties) {
+ String uri = "http://127.0.0.1:" + server.getAddress().getPort();
+ RESTCatalog restCatalog = mock(RESTCatalog.class);
+ when(restCatalog.name()).thenReturn("upstream");
+ when(restCatalog.properties())
+ .thenReturn(
+ ImmutableMap.<String, String>builder()
+ .put(CatalogProperties.URI, uri)
+ .put(AuthProperties.AUTH_TYPE, AuthProperties.AUTH_TYPE_NONE)
+ .putAll(extraCatalogProperties)
+ .build());
+
+ IcebergConfig config =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ IcebergConstants.WAREHOUSE,
+ "/tmp/warehouse"));
+ return new StaticCatalogWrapperForREST("local", config, restCatalog);
+ }
+
+ @FunctionalInterface
+ private interface RemoteConfigAssertion {
+ void accept(CatalogWrapperForREST wrapper, List<RecordedRequest> requests);
+ }
+
+ private static class RecordedRequest {
+ private final String path;
+ private final String rawQuery;
+
+ RecordedRequest(String path, String rawQuery) {
+ this.path = path;
+ this.rawQuery = rawQuery;
+ }
+ }
+
@Test
void testStagedCreateBuilderUsesDerivedMetadataV3() {
RESTCatalog catalog = mock(RESTCatalog.class);
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
index 8c572999b6..c10a6140b3 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/IcebergRestTestUtil.java
@@ -87,6 +87,16 @@ public class IcebergRestTestUtil {
public static final String RENAME_VIEW_PATH = V_1 + "/views/rename";
public static final String REPORT_METRICS_POSTFIX = "metrics";
+ /** Factory for creating {@link IcebergCatalogWrapperManager} instances in
tests. */
+ @FunctionalInterface
+ public interface WrapperManagerFactory {
+ IcebergCatalogWrapperManager create(
+ Map<String, String> properties,
+ IcebergConfigProvider configProvider,
+ boolean auxMode,
+ String metalakeName);
+ }
+
public static final boolean DEBUG_SERVER_LOG_ENABLED = true;
public static ResourceConfig getIcebergResourceConfig(Class c) {
@@ -95,6 +105,15 @@ public class IcebergRestTestUtil {
public static ResourceConfig getIcebergResourceConfig(
Class c, boolean bindIcebergTableOps, List<EventListenerPlugin>
eventListenerPlugins) {
+ return getIcebergResourceConfig(
+ c, bindIcebergTableOps, eventListenerPlugins,
IcebergCatalogWrapperManagerForTest::new);
+ }
+
+ public static ResourceConfig getIcebergResourceConfig(
+ Class c,
+ boolean bindIcebergTableOps,
+ List<EventListenerPlugin> eventListenerPlugins,
+ WrapperManagerFactory wrapperManagerFactory) {
ResourceConfig resourceConfig = new ResourceConfig();
resourceConfig.register(c);
resourceConfig.register(IcebergObjectMapperProvider.class).register(JacksonFeature.class);
@@ -140,7 +159,7 @@ public class IcebergRestTestUtil {
configProvider.initialize(catalogConf);
// used to override register table interface
IcebergCatalogWrapperManager icebergCatalogWrapperManager =
- new IcebergCatalogWrapperManagerForTest(
+ wrapperManagerFactory.create(
catalogConf, configProvider, false,
configProvider.getMetalakeName());
IcebergRESTServerContext.create(
configProvider, false, false, true, icebergCatalogWrapperManager);
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfig.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfig.java
index f60eebf36f..b9192d31db 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfig.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfig.java
@@ -126,6 +126,29 @@ public class TestIcebergConfig extends IcebergTestBase {
"Config response should contain view list endpoint for catalog that
supports views");
}
+ @Test
+ public void testConfigEndpointsContainScanPlanForNonRESTBackend() {
+ // Gravitino plans scans locally for non-REST backends (memory, hive,
jdbc), so the scan plan
+ // endpoint must be advertised.
+ String warehouseName = IcebergRestTestUtil.PREFIX;
+ Map<String, String> queryParams = ImmutableMap.of("warehouse",
warehouseName);
+ Response resp =
+ getIcebergClientBuilder(IcebergRestTestUtil.CONFIG_PATH,
Optional.of(queryParams)).get();
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
resp.getStatus());
+
+ ConfigResponse response = resp.readEntity(ConfigResponse.class);
+
+ boolean hasScanPlanEndpoint =
+ response.endpoints().stream()
+ .anyMatch(
+ endpoint ->
+ "POST".equals(endpoint.httpMethod())
+ &&
endpoint.path().contains("namespaces/{namespace}/tables/{table}/plan"));
+ Assertions.assertTrue(
+ hasScanPlanEndpoint,
+ "Config response must advertise scan plan endpoint for non-REST
backend catalogs");
+ }
+
@Test
public void testConfigScanPlanEndpointPathIsNamespaceScoped() {
// Iceberg 1.11+ advertises the namespace-scoped scan plan path via
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfigEndpointGating.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfigEndpointGating.java
new file mode 100644
index 0000000000..3fa6e38e7c
--- /dev/null
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/rest/TestIcebergConfigEndpointGating.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.iceberg.service.rest;
+
+import java.util.Collections;
+import java.util.Map;
+import javax.ws.rs.core.Application;
+import javax.ws.rs.core.Response;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
+import org.apache.gravitino.iceberg.service.CatalogWrapperForREST;
+import org.apache.gravitino.iceberg.service.IcebergCatalogWrapperManager;
+import org.apache.gravitino.iceberg.service.provider.IcebergConfigProvider;
+import org.apache.iceberg.rest.responses.ConfigResponse;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests that the /v1/config endpoint gates optional endpoints (like scan
planning) based on the
+ * catalog backend's capabilities.
+ */
+public class TestIcebergConfigEndpointGating extends IcebergTestBase {
+
+ @Override
+ protected Application configure() {
+ return IcebergRestTestUtil.getIcebergResourceConfig(
+ IcebergConfigOperations.class,
+ true,
+ Collections.emptyList(),
+ NoScanPlanWrapperManager::new);
+ }
+
+ @Test
+ public void testScanPlanEndpointOmittedWhenNotSupported() {
+ Response resp = getConfigClientBuilder().get();
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
resp.getStatus());
+
+ ConfigResponse response = resp.readEntity(ConfigResponse.class);
+
+ boolean hasScanPlanEndpoint =
+ response.endpoints().stream()
+ .anyMatch(
+ endpoint ->
+ "POST".equals(endpoint.httpMethod())
+ && endpoint.path().contains("tables/{table}/plan"));
+
+ Assertions.assertFalse(
+ hasScanPlanEndpoint,
+ "Config response must NOT advertise scan plan endpoint for catalogs
that do not support it");
+ }
+
+ @Test
+ public void testCoreEndpointsAlwaysPresent() {
+ Response resp = getConfigClientBuilder().get();
+ Assertions.assertEquals(Response.Status.OK.getStatusCode(),
resp.getStatus());
+
+ ConfigResponse response = resp.readEntity(ConfigResponse.class);
+
+ // Core table endpoints must always be present
+ boolean hasListTables =
+ response.endpoints().stream()
+ .anyMatch(
+ endpoint ->
+ "GET".equals(endpoint.httpMethod())
+ &&
endpoint.path().contains("namespaces/{namespace}/tables"));
+ Assertions.assertTrue(hasListTables, "Config must always advertise list
tables endpoint");
+
+ boolean hasLoadTable =
+ response.endpoints().stream()
+ .anyMatch(
+ endpoint ->
+ "GET".equals(endpoint.httpMethod())
+ && endpoint.path().contains("tables/{table}"));
+ Assertions.assertTrue(hasLoadTable, "Config must always advertise load
table endpoint");
+ }
+
+ /**
+ * Wrapper that reports no scan-plan support, standing in for a federated
catalog whose remote
+ * does not advertise {@code V1_SUBMIT_TABLE_SCAN_PLAN}. The remote lookup
itself is covered by
+ * {@code TestCatalogWrapperForREST}; this only pins the {@code /v1/config}
wiring.
+ */
+ static class NoScanPlanCatalogWrapper extends CatalogWrapperForTest {
+ public NoScanPlanCatalogWrapper(String catalogName, IcebergConfig
icebergConfig) {
+ super(catalogName, icebergConfig);
+ }
+
+ @Override
+ public boolean supportsScanPlanOperations() {
+ return false;
+ }
+ }
+
+ /** Manager that creates {@link NoScanPlanCatalogWrapper} instances. */
+ public static class NoScanPlanWrapperManager extends
IcebergCatalogWrapperManager {
+ public NoScanPlanWrapperManager(
+ Map<String, String> properties,
+ IcebergConfigProvider configProvider,
+ boolean auxMode,
+ String metalakeName) {
+ super(properties, configProvider, auxMode, metalakeName);
+ }
+
+ @Override
+ public CatalogWrapperForREST createCatalogWrapper(
+ String catalogName, IcebergConfig icebergConfig) {
+ return new NoScanPlanCatalogWrapper(catalogName, icebergConfig);
+ }
+ }
+}