This is an automated email from the ASF dual-hosted git repository.
roryqi 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 da99abcc36 [#11943] improvement(iceberg,core): validate warehouse at
catalog creation and fail fast with an actionable error (#11959)
da99abcc36 is described below
commit da99abcc36207f2c15c563dde657ab05d67ace82
Author: Nevin Zheng <[email protected]>
AuthorDate: Sun Jul 19 01:50:53 2026 -0700
[#11943] improvement(iceberg,core): validate warehouse at catalog creation
and fail fast with an actionable error (#11959)
### What changes were proposed in this pull request?
This PR fixes the visible configuration error reported in #11943 for a
`lakehouse-iceberg` catalog using `catalog-backend=rest`.
For the Iceberg REST backend, `warehouse` is forwarded to `GET
/v1/config?warehouse=...` as a server-side catalog selector. It is not a
storage location. Previously, a catalog could be created successfully
with an unresolvable value such as `s3://warehouse/`, then fail
cryptically on first use.
The changes are:
- **Validate the REST `warehouse` selector at creation time.**
`IcebergCatalog` opts into create-time initialization only when the
backend is REST and `warehouse` is nonblank. Hive, JDBC, and REST
catalogs without `warehouse` keep their existing creation behavior.
- **Return an actionable configuration error.** If the REST server
rejects the selector with `NoSuchWarehouseException`, creation fails
with `IllegalArgumentException` / HTTP 400 and explains that `warehouse`
should be removed to use the default catalog or set to a catalog name
the REST server recognizes.
- **Keep dependency failures distinct from invalid configuration.**
Because the new validation contacts the remote REST service, a transport
failure or HTTP 503 during REST initialization is returned as
`ConnectionFailedException` / HTTP 502 instead of being presented as an
invalid warehouse or a generic internal error. With a nonblank
`warehouse`, the failed creation is rolled back and no catalog is
persisted.
- **Preserve the exception across Gravitino boundaries.** The catalog
classloader, server handler, and Java client preserve
`IllegalArgumentException` and `ConnectionFailedException` so callers
receive the intended 400 or 502 result.
- **Clarify the property in OpenAPI examples.** The examples contrast
storage backends, where `warehouse` is a location, with the REST
backend, where it is a catalog selector and may be omitted.
#### Scope and future work
The primary scope of this PR is the `NoSuchWarehouseException` case from
#11943. The adjacent transport/503 mapping is included only so the new
create-time validation does not confuse a temporarily unavailable REST
dependency with an invalid warehouse value.
This PR does **not** attempt to normalize every response or exception
produced by the Iceberg REST client. Iceberg also has other response
types and endpoint-specific errors, including bad-request,
authorization, forbidden, service-failure, and operation-time failures
after initialization. Mapping those consistently into Gravitino's error
taxonomy should be handled as future work with dedicated scenarios and
tests.
### Why are the changes needed?
On a lakehouse-iceberg catalog with `catalog-backend=rest`, the
`warehouse` property is a catalog selector sent to the Iceberg REST
server, not a storage path. A user copying a Hive or JDBC example may
set it to a storage URI such as `s3://warehouse/`. Before this change,
catalog creation returned success and every later attempt to use the
catalog failed with a cryptic `NoSuchWarehouseException`.
The catalog should reject that specific misconfiguration at creation
time with a useful message. At the same time, a temporarily unavailable
remote server must remain distinguishable from an invalid selector.
Fix: #11943
### Does this PR introduce _any_ user-facing change?
- Creating a REST-backed lakehouse-iceberg catalog with a nonblank
`warehouse` that the remote server cannot resolve now fails at creation
with HTTP 400 and an actionable message.
- If the remote service is unreachable or returns HTTP 503 while REST
initialization is running, the failure is returned as HTTP 502
(`ConnectionFailedException`, error code `1007`). This can occur during
creation when `warehouse` is configured, or during lazy initialization
on first use when it is omitted.
- Creation behavior for REST catalogs without `warehouse` is unchanged:
initialization remains deferred until first use.
- Hive and JDBC behavior is unchanged.
- No property keys are added or removed.
### How was this patch tested?
**Unit tests** (`./gradlew :core:test
:catalogs:catalog-lakehouse-iceberg:test -PskipITs`):
- `TestCatalogManager#testCreateCatalogValidatesBackendConnection`
verifies opt-in create-time validation, rollback after validation
failure, and that catalogs which do not opt in are not eagerly
initialized.
- `TestIcebergCatalog#testShouldValidateWarehouseProperty` covers REST
with a value, missing/blank warehouse, JDBC, and a null backend.
-
`TestIcebergCatalogOperations#testHandleRestExceptionTranslatesUnavailableToConnectionFailed`
verifies the scoped initialization mapping for a transport failure and
HTTP 503.
-
`TestIcebergCatalogOperations#testTestConnectionNonRestWarehouseHasNoHint`
verifies that the REST-specific warehouse guidance is not shown for
other backends.
**Integration tests** (`CatalogIcebergRestIT`, against the embedded
Iceberg REST service):
- `testCreateRestCatalogWithUnresolvableWarehouseFailsAtCreate` verifies
that an invalid selector returns the actionable error and leaves no
catalog behind.
- `testCreateRestCatalogWithValidWarehouseSucceeds` verifies that a
named selector recognized by the REST server creates successfully and
can list schemas.
- `testCreateRestCatalogWithUnreachableServerAtCreate` verifies that a
dependency outage during create-time validation returns
`ConnectionFailedException` and persists nothing.
- `testOperationOnUnreachableRestCatalogFailsWithConnectionError`
verifies the lazy-initialization path: creation is allowed without
`warehouse`, and the first operation reports the unavailable REST
dependency.
Full `CatalogIcebergRestIT` run: 43 tests, 1 skipped, 0 failures.
Manual verification covered the three warehouse-selector cases:
- Recognized named selector → HTTP 200 and usable catalog.
- Unrecognized storage-URI-shaped selector → HTTP 400 with the
selector-versus-location explanation.
- Omitted selector → HTTP 200 using the server's default catalog.
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../catalog/lakehouse/iceberg/IcebergCatalog.java | 16 +++
.../iceberg/IcebergCatalogOperations.java | 47 ++++++--
.../lakehouse/iceberg/TestIcebergCatalog.java | 11 ++
.../iceberg/TestIcebergCatalogOperations.java | 53 +++++++++
.../integration/test/CatalogIcebergRestIT.java | 130 +++++++++++++++++++++
.../org/apache/gravitino/client/ErrorHandlers.java | 3 +
.../apache/gravitino/catalog/CatalogManager.java | 11 +-
.../apache/gravitino/connector/BaseCatalog.java | 9 ++
.../gravitino/utils/IsolatedClassLoader.java | 26 +++++
.../java/org/apache/gravitino/TestCatalog.java | 7 ++
.../gravitino/catalog/TestCatalogManager.java | 74 ++++++++++++
.../gravitino/connector/TestCatalogOperations.java | 10 +-
docs/open-api/catalogs.yaml | 49 ++++++++
.../server/web/rest/ExceptionHandlers.java | 10 ++
14 files changed, 446 insertions(+), 10 deletions(-)
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
index f7dc4acc06..935112c66d 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalog.java
@@ -68,6 +68,22 @@ public class IcebergCatalog extends
BaseCatalog<IcebergCatalog> {
return (ViewCatalog) ops();
}
+ @Override
+ public boolean shouldValidateOnCreate() {
+ Map<String, String> properties = entity().getProperties();
+ return properties != null
+ && shouldValidateWarehouseProperty(
+ properties.get(IcebergConstants.CATALOG_BACKEND),
+ properties.get(IcebergConstants.WAREHOUSE));
+ }
+
+ // REST resolves `warehouse` as a server-side catalog selector, so validate
it at create; hive and
+ // jdbc use it as a storage location and need no create-time connection.
+ static boolean shouldValidateWarehouseProperty(String backend, String
warehouse) {
+ return IcebergCatalogBackend.REST.name().equalsIgnoreCase(backend)
+ && StringUtils.isNotBlank(warehouse);
+ }
+
@Override
public Capability newCapability() {
return new
IcebergCatalogCapability(HierarchicalSchemaUtil.schemaSeparator());
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
index 0e7f8d48bd..3a75bad5ee 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/main/java/org/apache/gravitino/catalog/lakehouse/iceberg/IcebergCatalogOperations.java
@@ -80,6 +80,9 @@ import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.AlreadyExistsException;
import org.apache.iceberg.exceptions.NamespaceNotEmptyException;
import org.apache.iceberg.exceptions.NoSuchNamespaceException;
+import org.apache.iceberg.exceptions.NoSuchWarehouseException;
+import org.apache.iceberg.exceptions.RESTException;
+import org.apache.iceberg.exceptions.ServiceUnavailableException;
import org.apache.iceberg.rest.requests.RenameTableRequest;
import org.apache.iceberg.rest.requests.UpdateNamespacePropertiesRequest;
import org.apache.iceberg.rest.responses.GetNamespaceResponse;
@@ -128,14 +131,42 @@ public class IcebergCatalogOperations
IcebergCatalogWrapper rawWrapper = new
IcebergCatalogWrapper(icebergConfig);
- AuthenticationConfig authenticationConfig = new
AuthenticationConfig(resultConf);
- this.icebergCatalogWrapper =
- authenticationConfig.isKerberosAuth() && rawWrapper.getCatalog()
instanceof SupportsKerberos
- ? new
KerberosAwareIcebergCatalogProxy(rawWrapper).getProxy(icebergConfig)
- : rawWrapper;
- this.icebergCatalogWrapperHelper =
- new IcebergCatalogWrapperHelper(icebergCatalogWrapper.getCatalog());
- this.icebergViewCatalogOperations = new
IcebergViewCatalogOperations(icebergCatalogWrapper);
+ try {
+ AuthenticationConfig authenticationConfig = new
AuthenticationConfig(resultConf);
+ this.icebergCatalogWrapper =
+ authenticationConfig.isKerberosAuth()
+ && rawWrapper.getCatalog() instanceof SupportsKerberos
+ ? new
KerberosAwareIcebergCatalogProxy(rawWrapper).getProxy(icebergConfig)
+ : rawWrapper;
+ this.icebergCatalogWrapperHelper =
+ new IcebergCatalogWrapperHelper(icebergCatalogWrapper.getCatalog());
+ this.icebergViewCatalogOperations = new
IcebergViewCatalogOperations(icebergCatalogWrapper);
+ } catch (NoSuchWarehouseException e) {
+ // A reachable server rejecting the `warehouse` selector is a user
error. See issue #11943.
+ throw new IllegalArgumentException(
+ String.format(
+ "The 'warehouse' value '%s' could not be resolved by the Iceberg
REST server. On "
+ + "the REST backend 'warehouse' selects a catalog by name on
the remote server "
+ + "and is not a storage location; remove 'warehouse' to use
the server's default "
+ + "catalog, or set it to a catalog name/identifier that the
server recognizes.",
+ icebergConfig.get(IcebergConfig.CATALOG_WAREHOUSE)),
+ e);
+ } catch (RESTException e) {
+ throw handleRestException(e);
+ }
+ }
+
+ // Maps an Iceberg REST-client exception into Gravitino's taxonomy
+ @VisibleForTesting
+ static RuntimeException handleRestException(RESTException e) {
+ // Base RESTException (server unreachable) or ServiceUnavailableException
(503): the downstream
+ // dependency is not available. getClass() matches the base type only, so
the 4xx/5xx subtypes
+ // are excluded and pass through unchanged.
+ if (e.getClass() == RESTException.class || e instanceof
ServiceUnavailableException) {
+ return new ConnectionFailedException(
+ e, "The Iceberg REST backend is unavailable: %s", e.getMessage());
+ }
+ return e;
}
/** Closes the Iceberg catalog and releases the associated client pool. */
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalog.java
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalog.java
index 9249088593..622140e200 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalog.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalog.java
@@ -114,6 +114,17 @@ public class TestIcebergCatalog {
Assertions.assertTrue(listNamespacesResponse.namespaces().isEmpty());
}
+ @Test
+ public void testShouldValidateWarehouseProperty() {
+ Assertions.assertTrue(
+ IcebergCatalog.shouldValidateWarehouseProperty("rest",
"s3://warehouse/"));
+
Assertions.assertFalse(IcebergCatalog.shouldValidateWarehouseProperty("rest",
null));
+ Assertions.assertFalse(
+ IcebergCatalog.shouldValidateWarehouseProperty("jdbc",
"file:///tmp/iceberg-jdbc"));
+
Assertions.assertFalse(IcebergCatalog.shouldValidateWarehouseProperty("rest", "
"));
+
Assertions.assertFalse(IcebergCatalog.shouldValidateWarehouseProperty(null,
"s3://warehouse/"));
+ }
+
@Test
void testCatalogProperty() {
AuditInfo auditInfo =
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
index dfbd129a23..841d3ecfaa 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/TestIcebergCatalogOperations.java
@@ -27,8 +27,13 @@ import java.util.Arrays;
import org.apache.gravitino.Catalog;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
+import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.iceberg.common.ops.IcebergCatalogWrapper;
+import org.apache.iceberg.exceptions.BadRequestException;
+import org.apache.iceberg.exceptions.RESTException;
+import org.apache.iceberg.exceptions.ServiceUnavailableException;
import org.apache.iceberg.rest.responses.ListNamespacesResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -55,6 +60,54 @@ public class TestIcebergCatalogOperations {
exception.getMessage().contains("Failed to run listNamespace on
Iceberg catalog"));
}
+ @Test
+ public void testHandleRestExceptionTranslatesUnavailableToConnectionFailed()
{
+ // Transport failure (server unreachable) is the base RESTException.
+ RESTException unreachable = new RESTException("Error occurred while
processing GET request");
+ Assertions.assertInstanceOf(
+ ConnectionFailedException.class,
IcebergCatalogOperations.handleRestException(unreachable));
+
+ // The server answered 503.
+ RESTException unavailable = new ServiceUnavailableException("Service
Unavailable: %s", "down");
+ Assertions.assertInstanceOf(
+ ConnectionFailedException.class,
IcebergCatalogOperations.handleRestException(unavailable));
+
+ // A subtype we don't translate yet is returned unchanged (surfaces as a
runtime error).
+ RESTException badRequest = new BadRequestException("Malformed request:
%s", "bad");
+ Assertions.assertSame(badRequest,
IcebergCatalogOperations.handleRestException(badRequest));
+ }
+
+ @Test
+ public void testTestConnectionNonRestWarehouseHasNoHint() {
+ IcebergCatalogWrapper mockWrapper = mock(IcebergCatalogWrapper.class);
+ IcebergConfig config =
+ new IcebergConfig(
+ ImmutableMap.of(
+ IcebergConstants.CATALOG_BACKEND, "hive",
+ IcebergConstants.WAREHOUSE, "s3://warehouse/"));
+ when(mockWrapper.getIcebergConfig()).thenReturn(config);
+ when(mockWrapper.listNamespace(any())).thenThrow(new
RuntimeException("boom"));
+
+ IcebergCatalogOperations catalogOperations = new
IcebergCatalogOperations();
+ catalogOperations.icebergCatalogWrapper = mockWrapper;
+
+ Exception exception =
+ Assertions.assertThrows(
+ ConnectionFailedException.class,
+ () ->
+ catalogOperations.testConnection(
+ NameIdentifier.of(METALAKE, CATALOG),
+ Catalog.Type.RELATIONAL,
+ "iceberg",
+ "comment",
+ ImmutableMap.of()));
+ Assertions.assertTrue(
+ exception.getMessage().contains("Failed to run listNamespace on
Iceberg catalog"),
+ exception.getMessage());
+ Assertions.assertFalse(
+ exception.getMessage().contains("selects a catalog by name"),
exception.getMessage());
+ }
+
@Test
public void testListSchemasConvertsMultiLevelNamespacesToLogicalNames() {
IcebergCatalogWrapper mockWrapper = mock(IcebergCatalogWrapper.class);
diff --git
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergRestIT.java
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergRestIT.java
index f992821a8d..f6cb49554b 100644
---
a/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergRestIT.java
+++
b/catalogs/catalog-lakehouse-iceberg/src/test/java/org/apache/gravitino/catalog/lakehouse/iceberg/integration/test/CatalogIcebergRestIT.java
@@ -18,16 +18,46 @@
*/
package org.apache.gravitino.catalog.lakehouse.iceberg.integration.test;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
import java.util.Map;
+import org.apache.gravitino.Catalog;
import org.apache.gravitino.catalog.lakehouse.iceberg.IcebergConstants;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.iceberg.common.IcebergConfig;
import org.apache.gravitino.integration.test.container.HiveContainer;
+import org.apache.gravitino.integration.test.util.GravitinoITUtils;
import org.apache.gravitino.server.web.JettyServerConfig;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
@Tag("gravitino-docker-test")
public class CatalogIcebergRestIT extends CatalogIcebergBaseIT {
+ // A named (non-default) catalog registered on the embedded Iceberg REST
service so a `warehouse`
+ // selector that resolves to a real catalog can be exercised at create time.
+ private static final String NAMED_CATALOG = "it_named_catalog";
+
+ @Override
+ @BeforeAll
+ public void startup() throws Exception {
+ // Register the named catalog before the server (and its Iceberg REST
service) starts, so the
+ // REST service serves `NAMED_CATALOG` in addition to its default catalog.
Must run before
+ // super.startup() launches the embedded server.
+ String namedWarehouse = System.getProperty("java.io.tmpdir") + "/" +
NAMED_CATALOG + "-wh";
+ String prefix =
+ "gravitino." + IcebergConstants.GRAVITINO_ICEBERG_REST_SERVICE_NAME +
".catalog.";
+ registerCustomConfigs(
+ ImmutableMap.of(
+ prefix + NAMED_CATALOG + "." + IcebergConstants.CATALOG_BACKEND,
+ "memory",
+ prefix + NAMED_CATALOG + "." + IcebergConstants.WAREHOUSE,
+ namedWarehouse));
+ super.startup();
+ }
+
@Override
protected void initIcebergCatalogProperties() {
Map<String, String> map =
@@ -44,4 +74,104 @@ public class CatalogIcebergRestIT extends
CatalogIcebergBaseIT {
containerSuite.getHiveContainer().getContainerIpAddress(),
HiveContainer.HDFS_DEFAULTFS_PORT);
}
+
+ // Checks an Iceberg REST warehouse misconfiguration is caught on create.
See issue #11943.
+ @Test
+ void testCreateRestCatalogWithUnresolvableWarehouseFailsAtCreate() {
+ String badCatalogName =
GravitinoITUtils.genRandomName("iceberg_rest_bad_warehouse");
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(IcebergConfig.CATALOG_BACKEND.getKey(), "rest");
+ properties.put(IcebergConfig.CATALOG_URI.getKey(), URIS);
+ // A URI-shaped value copied from a hive/jdbc example; the REST server
does not know a catalog
+ // by this name, so it cannot be resolved.
+ properties.put(IcebergConfig.CATALOG_WAREHOUSE.getKey(),
"s3://not-a-real-catalog/");
+
+ // A reachable REST server rejecting the warehouse selector is a
user/configuration error, so
+ // it surfaces as an IllegalArgumentException (HTTP 400) at create time.
+ IllegalArgumentException exception =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ metalake.createCatalog(
+ badCatalogName,
+ Catalog.Type.RELATIONAL,
+ "lakehouse-iceberg",
+ "comment",
+ properties));
+
+ Assertions.assertTrue(
+ exception.getMessage() != null
+ && exception.getMessage().contains("selects a catalog by name")
+ && exception.getMessage().contains("s3://not-a-real-catalog/"),
+ "expected the REST warehouse name-vs-location hint, but got: " +
exception.getMessage());
+
+ Assertions.assertFalse(
+ metalake.catalogExists(badCatalogName),
+ "catalog must not be created when its warehouse cannot be resolved at
create time");
+ }
+
+ // A warehouse the REST server serves must still create and be usable. See
issue #11943.
+ @Test
+ void testCreateRestCatalogWithValidWarehouseSucceeds() {
+ String catalogName =
GravitinoITUtils.genRandomName("iceberg_rest_named_warehouse");
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(IcebergConfig.CATALOG_BACKEND.getKey(), "rest");
+ properties.put(IcebergConfig.CATALOG_URI.getKey(), URIS);
+ // A valid selector: NAMED_CATALOG is a catalog the embedded REST server
serves.
+ properties.put(IcebergConfig.CATALOG_WAREHOUSE.getKey(), NAMED_CATALOG);
+
+ Catalog created =
+ metalake.createCatalog(
+ catalogName, Catalog.Type.RELATIONAL, "lakehouse-iceberg",
"comment", properties);
+ try {
+ Assertions.assertTrue(metalake.catalogExists(catalogName));
+ // Resolvable end-to-end: listing schemas against the selected catalog
succeeds.
+ Assertions.assertDoesNotThrow(() -> created.asSchemas().listSchemas());
+ } finally {
+ metalake.disableCatalog(catalogName);
+ metalake.dropCatalog(catalogName);
+ }
+ }
+
+ // An unreachable remote at create fails as a ConnectionFailedException and
persists nothing.
+ @Test
+ void testCreateRestCatalogWithUnreachableServerAtCreate() {
+ String name =
GravitinoITUtils.genRandomName("iceberg_rest_unreachable_server");
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(IcebergConfig.CATALOG_BACKEND.getKey(), "rest");
+ // Port 1 refuses connections fast, so create-time validation cannot reach
the REST server.
+ properties.put(IcebergConfig.CATALOG_URI.getKey(),
"http://127.0.0.1:1/iceberg/");
+ properties.put(IcebergConfig.CATALOG_WAREHOUSE.getKey(), "some_warehouse");
+
+ Assertions.assertThrows(
+ ConnectionFailedException.class,
+ () ->
+ metalake.createCatalog(
+ name, Catalog.Type.RELATIONAL, "lakehouse-iceberg", "comment",
properties));
+ Assertions.assertFalse(metalake.catalogExists(name));
+ }
+
+ // A catalog with valid config whose backend is unreachable: it is created
(no warehouse, so no
+ // create-time validation), but querying the dead backend fails as a
ConnectionFailedException.
+ @Test
+ void testOperationOnUnreachableRestCatalogFailsWithConnectionError() {
+ String name =
GravitinoITUtils.genRandomName("iceberg_rest_unreachable_op");
+ Map<String, String> properties = Maps.newHashMap();
+ properties.put(IcebergConfig.CATALOG_BACKEND.getKey(), "rest");
+ // Port 1 refuses connections fast; no warehouse means create-time
validation is skipped.
+ properties.put(IcebergConfig.CATALOG_URI.getKey(),
"http://127.0.0.1:1/iceberg/");
+
+ Catalog created =
+ metalake.createCatalog(
+ name, Catalog.Type.RELATIONAL, "lakehouse-iceberg", "comment",
properties);
+ try {
+ Assertions.assertTrue(metalake.catalogExists(name));
+ // Config is valid but the backend is down, so querying it is a
dependency failure.
+ Assertions.assertThrows(
+ ConnectionFailedException.class, () ->
created.asSchemas().listSchemas());
+ } finally {
+ metalake.disableCatalog(name);
+ metalake.dropCatalog(name);
+ }
+ }
}
diff --git
a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
index dc14f4c6d8..79d4ca6903 100644
---
a/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
+++
b/clients/client-java/src/main/java/org/apache/gravitino/client/ErrorHandlers.java
@@ -1374,6 +1374,9 @@ public class ErrorHandlers {
@Override
public void accept(ErrorResponse errorResponse) {
+ if (errorResponse.getCode() == ErrorConstants.CONNECTION_FAILED_CODE) {
+ throw new ConnectionFailedException("%s",
formatErrorMessage(errorResponse));
+ }
if (errorResponse.getCode() == ErrorConstants.FORBIDDEN_CODE) {
throw new ForbiddenException("Forbidden error :%s",
errorResponse.getMessage());
}
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
index 4df78ad21b..e26e28d3a9 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CatalogManager.java
@@ -86,6 +86,7 @@ import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
import org.apache.gravitino.exceptions.CatalogInUseException;
import org.apache.gravitino.exceptions.CatalogNotInUseException;
+import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.exceptions.NoSuchEntityException;
@@ -1138,9 +1139,17 @@ public class CatalogManager implements
CatalogDispatcher, Closeable {
// so that AppClassLoader can get the value of properties.
wrapper.catalog.properties();
wrapper.catalog.capability();
+
+ // Eagerly initialize opted-in catalogs so a misconfiguration fails
at create rather than
+ // on first use. The backend translates failures into the
caller-error vs
+ // dependency-unavailable taxonomy; both types are forwarded by the
passthrough below.
+ if (propsToValidate != null &&
wrapper.catalog.shouldValidateOnCreate()) {
+ wrapper.catalog.ops();
+ }
return null;
},
- IllegalArgumentException.class);
+ IllegalArgumentException.class,
+ ConnectionFailedException.class);
return wrapper;
}
diff --git a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
index 4ee0b9c794..0c66ea2242 100644
--- a/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
+++ b/core/src/main/java/org/apache/gravitino/connector/BaseCatalog.java
@@ -211,6 +211,15 @@ public abstract class BaseCatalog<T extends BaseCatalog>
return ops;
}
+ /**
+ * Whether to validate this catalog against its backend at create time
rather than on first use.
+ *
+ * @return {@code true} to validate at create; {@code false} (default) to
defer
+ */
+ public boolean shouldValidateOnCreate() {
+ return false;
+ }
+
public void checkMetalakeAndCatalogInUse() {
Map<String, String> catalogProperties = entity.getProperties();
if (catalogProperties == null) {
diff --git
a/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
b/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
index 6c80a6caba..82eb7213e7 100644
--- a/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
+++ b/core/src/main/java/org/apache/gravitino/utils/IsolatedClassLoader.java
@@ -111,6 +111,32 @@ public class IsolatedClassLoader implements Closeable {
}
}
+ /**
+ * Executes {@code fn} within the isolated class loading context, rethrowing
any of the given
+ * {@code passthroughExceptions} unchanged and wrapping everything else in a
{@link
+ * RuntimeException}.
+ *
+ * @param fn The function to be executed within the isolated class loading
context.
+ * @param passthroughExceptions Exception types to rethrow as-is instead of
wrapping.
+ * @param <T> The type of value returned by this method.
+ * @return The return value of the fn that executed within the classloader.
+ */
+ @SafeVarargs
+ public final <T> T withClassLoader(
+ ThrowableFunction<ClassLoader, T> fn,
+ Class<? extends RuntimeException>... passthroughExceptions) {
+ try {
+ return withClassLoader(fn);
+ } catch (Exception e) {
+ for (Class<? extends RuntimeException> exceptionClass :
passthroughExceptions) {
+ if (exceptionClass.isInstance(e)) {
+ throw (RuntimeException) e;
+ }
+ }
+ throw new RuntimeException(e);
+ }
+ }
+
public static IsolatedClassLoader buildClassLoader(List<String>
libAndResourcesPaths) {
// Listing all the classPath under the package path and build the isolated
class loader.
List<URL> classPathContents = Lists.newArrayList();
diff --git a/core/src/test/java/org/apache/gravitino/TestCatalog.java
b/core/src/test/java/org/apache/gravitino/TestCatalog.java
index fbea6b5e59..4e8e8ada24 100644
--- a/core/src/test/java/org/apache/gravitino/TestCatalog.java
+++ b/core/src/test/java/org/apache/gravitino/TestCatalog.java
@@ -60,6 +60,13 @@ public class TestCatalog extends BaseCatalog<TestCatalog> {
return new TestCatalogOperations(config);
}
+ @Override
+ public boolean shouldValidateOnCreate() {
+ Map<String, String> properties = entity().getProperties();
+ return properties != null
+ &&
Boolean.parseBoolean(properties.get(TestCatalogOperations.VALIDATE_ON_CREATE));
+ }
+
@Override
protected Capability newCapability() {
return new TestCatalogCapabilities();
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index 6d2ad36bf1..ddc57cc556 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -52,6 +52,7 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.Schema;
import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.TestCatalogOperations;
import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.connector.capability.CapabilityResult;
import org.apache.gravitino.exceptions.CatalogAlreadyExistsException;
@@ -388,6 +389,79 @@ public class TestCatalogManager {
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(failedIdent));
}
+ @Test
+ public void testCreateCatalogValidatesBackendConnection() {
+ Map<String, String> okProps =
+ Maps.newHashMap(
+ ImmutableMap.of(
+ PROPERTY_KEY1,
+ "value1",
+ PROPERTY_KEY2,
+ "value2",
+ PROPERTY_KEY5_PREFIX + "1",
+ "value3",
+ TestCatalogOperations.VALIDATE_ON_CREATE,
+ "true"));
+
+ // Opted in + backend resolves: creation succeeds.
+ NameIdentifier okIdent = NameIdentifier.of("metalake", "validate-ok");
+ Assertions.assertDoesNotThrow(
+ () ->
+ catalogManager.createCatalog(
+ okIdent, Catalog.Type.RELATIONAL, provider, "comment",
okProps));
+
Assertions.assertNotNull(catalogManager.getCatalogCache().getIfPresent(okIdent));
+
+ // Opted in + backend cannot be resolved: creation fails fast and leaves
nothing behind.
+ NameIdentifier failIdent = NameIdentifier.of("metalake", "validate-fail");
+ Map<String, String> failProps =
+ Maps.newHashMap(
+ ImmutableMap.of(
+ PROPERTY_KEY1,
+ "value1",
+ PROPERTY_KEY2,
+ "value2",
+ PROPERTY_KEY5_PREFIX + "1",
+ "value3",
+ TestCatalogOperations.VALIDATE_ON_CREATE,
+ "true",
+ TestCatalogOperations.FAIL_INITIALIZE,
+ "true"));
+ Throwable failure =
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ catalogManager.createCatalog(
+ failIdent, Catalog.Type.RELATIONAL, provider, "comment",
failProps));
+ Assertions.assertTrue(
+ failure.getMessage().contains("backend rejected catalog
configuration"),
+ failure.getMessage());
+
Assertions.assertNull(catalogManager.getCatalogCache().getIfPresent(failIdent));
+ // Rolled back, so the identifier is reusable.
+ Assertions.assertDoesNotThrow(
+ () ->
+ catalogManager.createCatalog(
+ failIdent, Catalog.Type.RELATIONAL, provider, "comment",
okProps));
+
+ // Not opted in: the connection is not validated at create even if it
would fail.
+ NameIdentifier skipIdent = NameIdentifier.of("metalake", "validate-skip");
+ Map<String, String> skipProps =
+ Maps.newHashMap(
+ ImmutableMap.of(
+ PROPERTY_KEY1,
+ "value1",
+ PROPERTY_KEY2,
+ "value2",
+ PROPERTY_KEY5_PREFIX + "1",
+ "value3",
+ TestCatalogOperations.FAIL_INITIALIZE,
+ "true"));
+ Assertions.assertDoesNotThrow(
+ () ->
+ catalogManager.createCatalog(
+ skipIdent, Catalog.Type.RELATIONAL, provider, "comment",
skipProps));
+
Assertions.assertNotNull(catalogManager.getCatalogCache().getIfPresent(skipIdent));
+ }
+
@Test
public void testListCatalogs() {
NameIdentifier ident = NameIdentifier.of("metalake", "test11");
diff --git
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
index b6324652ca..5fd405c62f 100644
---
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -134,6 +134,10 @@ public class TestCatalogOperations
public static final String FAIL_TEST = "need-fail";
+ public static final String VALIDATE_ON_CREATE = "validate-on-create";
+
+ public static final String FAIL_INITIALIZE = "fail-initialize";
+
private static final String SLASH = "/";
public TestCatalogOperations(Map<String, String> config) {
@@ -150,7 +154,11 @@ public class TestCatalogOperations
@Override
public void initialize(
Map<String, String> config, CatalogInfo info, HasPropertyMetadata
propertyMetadata)
- throws RuntimeException {}
+ throws RuntimeException {
+ if (config != null && "true".equals(config.get(FAIL_INITIALIZE))) {
+ throw new IllegalArgumentException("Simulated: backend rejected catalog
configuration");
+ }
+ }
@Override
public void close() throws IOException {}
diff --git a/docs/open-api/catalogs.yaml b/docs/open-api/catalogs.yaml
index 54fb971bd4..f568b04c52 100644
--- a/docs/open-api/catalogs.yaml
+++ b/docs/open-api/catalogs.yaml
@@ -61,6 +61,10 @@ paths:
examples:
CatalogCreate:
$ref: "#/components/examples/CatalogCreate"
+ CatalogCreateIcebergStorageBackend:
+ $ref:
"#/components/examples/CatalogCreateIcebergStorageBackend"
+ CatalogCreateIcebergRestBackend:
+ $ref: "#/components/examples/CatalogCreateIcebergRestBackend"
responses:
"200":
@@ -93,6 +97,10 @@ paths:
examples:
CatalogCreate:
$ref: "#/components/examples/CatalogCreate"
+ CatalogCreateIcebergStorageBackend:
+ $ref:
"#/components/examples/CatalogCreateIcebergStorageBackend"
+ CatalogCreateIcebergRestBackend:
+ $ref: "#/components/examples/CatalogCreateIcebergRestBackend"
responses:
"200":
description: Test connection completed
@@ -580,6 +588,47 @@ components:
}
}
+ # Iceberg with a storage backend (hive/jdbc): `warehouse` is a physical
storage LOCATION.
+ CatalogCreateIcebergStorageBackend:
+ summary: "Iceberg catalog (hive/jdbc backend): warehouse is a storage
location"
+ description: >-
+ For the `lakehouse-iceberg` provider with `catalog-backend` set to
`hive` or `jdbc`,
+ the `warehouse` property is a physical storage location (e.g.
`s3a://bucket/path` or
+ `hdfs://ns/path`) and is required.
+ value: {
+ "name": "my_iceberg_catalog",
+ "type": "relational",
+ "provider": "lakehouse-iceberg",
+ "comment": "Iceberg catalog backed by a Hive metastore",
+ "properties": {
+ "catalog-backend": "hive",
+ "uri": "thrift://127.0.0.1:9083",
+ "warehouse": "s3a://my-bucket/warehouse"
+ }
+ }
+
+ # Iceberg with the REST backend: `warehouse` is a CATALOG SELECTOR, not a
storage location.
+ CatalogCreateIcebergRestBackend:
+ summary: "Iceberg catalog (REST backend): warehouse selects a catalog,
or omit for the default"
+ description: >-
+ For the `lakehouse-iceberg` provider with `catalog-backend` set to
`rest`, the
+ `warehouse` property is NOT a storage location. It is forwarded to the
Iceberg REST
+ server (`GET /v1/config?warehouse=...`) as a catalog selector: a
catalog name for
+ Gravitino's Iceberg REST server (omit it to use the default catalog),
or a
+ server-defined identifier such as an Amazon S3 Tables bucket ARN. A
storage URI like
+ `s3://...` here is a common mistake copied from hive/jdbc examples and
will fail to
+ resolve.
+ value: {
+ "name": "my_iceberg_rest_catalog",
+ "type": "relational",
+ "provider": "lakehouse-iceberg",
+ "comment": "Iceberg catalog backed by a remote Iceberg REST server",
+ "properties": {
+ "catalog-backend": "rest",
+ "uri": "http://127.0.0.1:9001/iceberg"
+ }
+ }
+
CatalogResponse:
value: {
"code": 0,
diff --git
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
index 2476c3f7e5..6b550fd869 100644
---
a/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
+++
b/server/src/main/java/org/apache/gravitino/server/web/rest/ExceptionHandlers.java
@@ -1095,6 +1095,16 @@ public class ExceptionHandlers {
String errorMsg =
getBaseErrorMsg(formattedObject, op.name(), formattedParent,
getErrorMsg(e));
+
+ // A backend a catalog federates to being unreachable is a
downstream-dependency failure, not
+ // an internal Gravitino error: surface it as 502 Bad Gateway so callers
can tell a dependency
+ // outage from a server bug.
+ if (e instanceof ConnectionFailedException) {
+ // WARN, not ERROR: a dependency outage is not a Gravitino bug, but
still trace it here.
+ LOG.warn(errorMsg, e);
+ return Utils.connectionFailed(errorMsg, e);
+ }
+
LOG.error(errorMsg, e);
return Utils.internalError(errorMsg, e);
}