This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 2ce6bdbb46 [#14272] fix(lance): refuse catalog-level DropNamespace on
non-lakehouse catalogs (#14273)
2ce6bdbb46 is described below
commit 2ce6bdbb46807c81cae38892e981fad82e27c36d
Author: Nevin Zheng <[email protected]>
AuthorDate: Tue Oct 27 19:42:52 2026 -0700
[#14272] fix(lance): refuse catalog-level DropNamespace on non-lakehouse
catalogs (#14273)
### What changes were proposed in this pull request?
- **`GravitinoLanceNameSpaceOperations.dropCatalog`** now loads the
catalog and applies the lakehouse
scope check before calling the backing `dropCatalog`. A non-lakehouse
catalog is refused with the same
`NamespaceNotFoundException` ("Catalog is not a lakehouse catalog:
<name>") that describe and exists
return, and the backing drop is never called.
- A missing catalog keeps its current handling (`FAIL` gives "Catalog
not found", `SKIP` succeeds) and
is no longer passed to the backing drop.
- **`GravitinoLanceNamespaceWrapper`:** the check in
`loadAndValidateLakehouseCatalog` moves into a
package-private `validateLakehouseCatalog`, so both paths throw the same
error.
### Why are the changes needed?
**The catalog-level drop was the only Lance operation without the
lakehouse scope check.** A Lance client
could drop any catalog in the metalake, Hive or Iceberg included,
although Lance cannot list or describe
those catalogs. The default `RESTRICT` request also reached the catalog
hook's privilege cleanup before
the drop was refused as in use (that ordering is fixed separately in
#14265).
Fix: #14272
### Does this PR introduce _any_ user-facing change?
Yes. Lance `DropNamespace` on a one-level id that names a non-lakehouse
catalog now returns
NamespaceNotFound (404) in every mode and behavior, and the catalog is
left unchanged. Lakehouse
catalogs and missing catalogs behave as before.
### How was this patch tested?
New cases in `TestGravitinoLanceNameSpaceOperations`, with a real
wrapper over a stubbed
`CatalogOperator`, so the production scope check runs:
- **Refused:** non-lakehouse catalog with no behavior, `RESTRICT` and
`CASCADE`; every non-lakehouse
kind (Iceberg, MySQL, fileset, and the `lakehouse-generic` provider on
the wrong type); `SKIP` mode;
the error matches describe.
- **Guards:** a lakehouse catalog still drops (`RESTRICT` →
`force=false`, `CASCADE` → `true`); a
lakehouse catalog in use keeps its error; a missing catalog keeps its
behavior.
Results:
- **On `main`:** 10 of the 17 `TestGravitinoLanceNameSpaceOperations`
cases fail (the non-lakehouse catalog is dropped); the 7 guards pass.
- **With the fix:** all 17 pass, as do the other Lance tests in
`lance-common` and `lance-rest-server`
(`TestGravitinoLanceNamespaceWrapper`,
`TestGravitinoLanceTableOperations` and others).
`./gradlew -PskipITs :lance:lance-common:test
:lance:lance-rest-server:test --tests 'org.apache.gravitino.lance.*'`
Co-authored-by: Qi Yu <[email protected]>
---
.../GravitinoLanceNameSpaceOperations.java | 29 ++--
.../gravitino/GravitinoLanceNamespaceWrapper.java | 14 +-
.../TestGravitinoLanceNameSpaceOperations.java | 166 +++++++++++++++++++++
3 files changed, 199 insertions(+), 10 deletions(-)
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
index 2ca476ed9b..17b9b198b0 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNameSpaceOperations.java
@@ -361,20 +361,23 @@ public class GravitinoLanceNameSpaceOperations implements
LanceNamespaceOperatio
private DropNamespaceResponse dropCatalog(
String catalogName, DropMode mode, DropBehavior behavior) {
+ // Only a lakehouse catalog is a Lance namespace. Check the scope before
the drop, as describe
+ // and exists do, so that a Lance request can never drop (or start
dropping) another catalog.
+ Catalog catalog;
+ try {
+ catalog = namespaceWrapper.loadCatalog(catalogName);
+ } catch (NoSuchCatalogException e) {
+ return catalogNotFound(catalogName, mode);
+ }
+ namespaceWrapper.validateLakehouseCatalog(catalogName, catalog);
+
try {
boolean dropped = namespaceWrapper.dropCatalog(catalogName, behavior ==
DropBehavior.CASCADE);
if (dropped) {
return new DropNamespaceResponse();
- } else {
- // Catalog did not exist
- if (mode == DropMode.FAIL) {
- throw new NamespaceNotFoundException(
- "Catalog not found: " + catalogName,
- CommonUtil.formatCurrentStackTrace(),
- catalogName);
- }
- return new DropNamespaceResponse(); // SKIP mode
}
+ // Catalog did not exist
+ return catalogNotFound(catalogName, mode);
} catch (NonEmptyCatalogException e) {
throw new InvalidInputException(
String.format("Catalog %s is not empty", catalogName),
@@ -393,6 +396,14 @@ public class GravitinoLanceNameSpaceOperations implements
LanceNamespaceOperatio
}
}
+ private DropNamespaceResponse catalogNotFound(String catalogName, DropMode
mode) {
+ if (mode == DropMode.FAIL) {
+ throw new NamespaceNotFoundException(
+ "Catalog not found: " + catalogName,
CommonUtil.formatCurrentStackTrace(), catalogName);
+ }
+ return new DropNamespaceResponse(); // SKIP mode
+ }
+
private DropNamespaceResponse dropSchema(
String catalogName, String schemaName, DropMode mode, DropBehavior
behavior) {
try {
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNamespaceWrapper.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNamespaceWrapper.java
index d6880ce320..b36ac200ef 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNamespaceWrapper.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceNamespaceWrapper.java
@@ -205,13 +205,25 @@ public class GravitinoLanceNamespaceWrapper extends
NamespaceWrapper {
throw new NamespaceNotFoundException(
"Catalog not found: " + catalogName,
CommonUtil.formatCurrentStackTrace(), catalogName);
}
+ validateLakehouseCatalog(catalogName, catalog);
+ return catalog;
+ }
+
+ /**
+ * Rejects a catalog that is not a lakehouse catalog. Lance only exposes
lakehouse catalogs as
+ * namespaces, so any other catalog is reported as a missing namespace.
+ *
+ * @param catalogName the catalog name used in the error
+ * @param catalog the loaded catalog
+ * @throws NamespaceNotFoundException if the catalog is not a lakehouse
catalog
+ */
+ void validateLakehouseCatalog(String catalogName, Catalog catalog) {
if (!isLakehouseCatalog(catalog)) {
throw new NamespaceNotFoundException(
"Catalog is not a lakehouse catalog: " + catalogName,
CommonUtil.formatCurrentStackTrace(),
catalogName);
}
- return catalog;
}
String[] listSchemas(Catalog catalog) throws NoSuchCatalogException {
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNameSpaceOperations.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNameSpaceOperations.java
index 51629e4478..078049bb04 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNameSpaceOperations.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/common/ops/gravitino/TestGravitinoLanceNameSpaceOperations.java
@@ -18,19 +18,32 @@
*/
package org.apache.gravitino.lance.common.ops.gravitino;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.regex.Pattern;
import org.apache.gravitino.Catalog;
+import org.apache.gravitino.exceptions.CatalogInUseException;
+import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.lance.namespace.errors.InvalidInputException;
import org.lance.namespace.errors.NamespaceNotFoundException;
+import org.lance.namespace.model.DropNamespaceResponse;
import org.lance.namespace.model.ListNamespacesResponse;
import org.mockito.Mockito;
class TestGravitinoLanceNameSpaceOperations {
private static final String DELIMITER = Pattern.quote(".");
+ private static final String LAKEHOUSE_PROVIDER = "lakehouse-generic";
@Test
void testListNamespacesOnSchemaReturnsEmptyList() {
@@ -91,4 +104,157 @@ class TestGravitinoLanceNameSpaceOperations {
() -> operations.listNamespaces("bogus_catalog", DELIMITER, null,
null));
Assertions.assertEquals("Catalog not found: bogus_catalog",
exception.getMessage());
}
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {"restrict", "cascade"})
+ void testDropCatalogRefusesNonLakehouseCatalog(String behavior) {
+ // A Hive catalog is invisible to Lance list, describe and exists, so
Lance must not drop it.
+ // The default (null) behavior is RESTRICT, which would otherwise reach
the catalog hook's
+ // privilege cleanup before the drop is refused as "in use".
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ Catalog catalog = mockCatalog(Catalog.Type.RELATIONAL, "hive");
+ when(operator.loadCatalog("sales")).thenReturn(catalog);
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ NamespaceNotFoundException exception =
+ Assertions.assertThrows(
+ NamespaceNotFoundException.class,
+ () -> operations.dropNamespace("sales", DELIMITER, "fail",
behavior));
+
+ Assertions.assertEquals("Catalog is not a lakehouse catalog: sales",
exception.getMessage());
+ Assertions.assertEquals("sales", exception.getInstance());
+ verify(operator, never()).dropCatalog(anyString(), anyBoolean());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "RELATIONAL, lakehouse-iceberg",
+ "RELATIONAL, jdbc-mysql",
+ "FILESET, fileset",
+ "FILESET, lakehouse-generic",
+ "MODEL, lakehouse-generic"
+ })
+ void testDropCatalogRefusesEveryNonLakehouseKind(Catalog.Type type, String
provider) {
+ // Both the type and the provider must match. The right provider on the
wrong type is refused.
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ Catalog catalog = mockCatalog(type, provider);
+ when(operator.loadCatalog("other")).thenReturn(catalog);
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ Assertions.assertThrows(
+ NamespaceNotFoundException.class,
+ () -> operations.dropNamespace("other", DELIMITER, "fail", "cascade"));
+ verify(operator, never()).dropCatalog(anyString(), anyBoolean());
+ }
+
+ @Test
+ void testDropCatalogRefusesNonLakehouseCatalogInSkipMode() {
+ // SKIP only tolerates a missing catalog. An existing non-lakehouse
catalog is still refused,
+ // as it is for a schema-level drop under a non-lakehouse catalog.
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ Catalog catalog = mockCatalog(Catalog.Type.RELATIONAL, "hive");
+ when(operator.loadCatalog("sales")).thenReturn(catalog);
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ Assertions.assertThrows(
+ NamespaceNotFoundException.class,
+ () -> operations.dropNamespace("sales", DELIMITER, "skip", "cascade"));
+ verify(operator, never()).dropCatalog(anyString(), anyBoolean());
+ }
+
+ @Test
+ void testDropCatalogErrorMatchesDescribeForNonLakehouseCatalog() {
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ Catalog catalog = mockCatalog(Catalog.Type.RELATIONAL, "hive");
+ when(operator.loadCatalog("sales")).thenReturn(catalog);
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ NamespaceNotFoundException describeException =
+ Assertions.assertThrows(
+ NamespaceNotFoundException.class,
+ () -> operations.describeNamespace("sales", DELIMITER));
+ Exception dropException =
+ Assertions.assertThrows(
+ Exception.class, () -> operations.dropNamespace("sales",
DELIMITER, null, null));
+
+ Assertions.assertEquals(describeException.getClass(),
dropException.getClass());
+ Assertions.assertEquals(describeException.getMessage(),
dropException.getMessage());
+ Assertions.assertEquals(
+ describeException.getInstance(),
+ ((NamespaceNotFoundException) dropException).getInstance());
+ }
+
+ @ParameterizedTest
+ @CsvSource({"restrict, false", "cascade, true"})
+ void testDropLakehouseCatalogStillDrops(String behavior, boolean
expectedForce) {
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ Catalog catalog = mockCatalog(Catalog.Type.RELATIONAL, LAKEHOUSE_PROVIDER);
+ when(operator.loadCatalog("lance_cat")).thenReturn(catalog);
+ when(operator.dropCatalog("lance_cat", expectedForce)).thenReturn(true);
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ DropNamespaceResponse response =
+ operations.dropNamespace("lance_cat", DELIMITER, "fail", behavior);
+
+ Assertions.assertNotNull(response);
+ verify(operator).dropCatalog("lance_cat", expectedForce);
+ }
+
+ @Test
+ void testDropLakehouseCatalogInUseKeepsExistingError() {
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ Catalog catalog = mockCatalog(Catalog.Type.RELATIONAL, LAKEHOUSE_PROVIDER);
+ when(operator.loadCatalog("lance_cat")).thenReturn(catalog);
+ when(operator.dropCatalog("lance_cat", false))
+ .thenThrow(new CatalogInUseException("Catalog %s is in use",
"lance_cat"));
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ InvalidInputException exception =
+ Assertions.assertThrows(
+ InvalidInputException.class,
+ () -> operations.dropNamespace("lance_cat", DELIMITER, null,
null));
+ Assertions.assertEquals("Catalog lance_cat is in use",
exception.getMessage());
+ }
+
+ @Test
+ void testDropMissingCatalogKeepsExistingBehavior() {
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator = mockOperator();
+ when(operator.loadCatalog("bogus"))
+ .thenThrow(new NoSuchCatalogException("Catalog %s does not exist",
"bogus"));
+ when(operator.dropCatalog(anyString(), anyBoolean())).thenReturn(false);
+ GravitinoLanceNameSpaceOperations operations = newOperations(operator);
+
+ NamespaceNotFoundException exception =
+ Assertions.assertThrows(
+ NamespaceNotFoundException.class,
+ () -> operations.dropNamespace("bogus", DELIMITER, "fail",
"restrict"));
+ Assertions.assertEquals("Catalog not found: bogus",
exception.getMessage());
+ Assertions.assertEquals("bogus", exception.getInstance());
+
+ Assertions.assertNotNull(operations.dropNamespace("bogus", DELIMITER,
"skip", "cascade"));
+ }
+
+ private static GravitinoLanceNamespaceWrapper.CatalogOperator mockOperator()
{
+ // By default the backing drop succeeds, as it would for any catalog the
caller owns.
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator =
+ Mockito.mock(GravitinoLanceNamespaceWrapper.CatalogOperator.class);
+ when(operator.dropCatalog(anyString(), anyBoolean())).thenReturn(true);
+ return operator;
+ }
+
+ private static GravitinoLanceNameSpaceOperations newOperations(
+ GravitinoLanceNamespaceWrapper.CatalogOperator operator) {
+ // A real wrapper, so the scope check under test is the production one.
+ GravitinoLanceNamespaceWrapper wrapper = new
GravitinoLanceNamespaceWrapper();
+ wrapper.setCatalogOperator(operator);
+ return new GravitinoLanceNameSpaceOperations(wrapper);
+ }
+
+ private static Catalog mockCatalog(Catalog.Type type, String provider) {
+ Catalog catalog = Mockito.mock(Catalog.class);
+ when(catalog.type()).thenReturn(type);
+ when(catalog.provider()).thenReturn(provider);
+ return catalog;
+ }
}