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 15778b188f [#11893] fix(lance): Add external table guard to
deregisterTable (#11896)
15778b188f is described below
commit 15778b188f10bbd8cd5698eb5788884611b09e0f
Author: StormSpirit <[email protected]>
AuthorDate: Mon Jul 6 09:13:22 2026 +0800
[#11893] fix(lance): Add external table guard to deregisterTable (#11896)
### What changes were proposed in this pull request?
Add a guard in `GravitinoLanceTableOperations.deregisterTable()` that
rejects
non-external (managed) tables with `UnsupportedOperationException`
before calling
`dropTable`.
The guard reads `PROPERTY_EXTERNAL` from the loaded table properties
using the same
`Optional.ofNullable` + `Boolean.parseBoolean` pattern as the downstream
`LanceTableOperations.dropTable()`, ensuring consistent behavior across
both layers.
### Why are the changes needed?
`deregisterTable()` delegates to `dropTable()`, which for managed tables
deletes the
underlying Lance dataset — violating the interface contract ("It will
not delete the
underlying lance data").
The current code is safe only because all REST-created tables are forced
to
`PROPERTY_EXTERNAL=true` (lines 190, 221, 248). This is an
implementation coincidence,
not a semantic guarantee. If a managed table path is ever introduced,
`deregisterTable`
would silently delete physical data.
The guard converts this implicit assumption into an explicit invariant:
refuse
non-external tables rather than risk silent data loss. This mirrors
`HiveCatalogOperations.purgeTable()`, which throws
`UnsupportedOperationException` for
external tables — same exception type, opposite direction (purge rejects
external
tables; deregister rejects managed tables).
Fix: #11893
### Does this PR introduce _any_ user-facing change?
`deregisterTable` now throws `UnsupportedOperationException` for managed
tables, which
`LanceExceptionMapper` maps to HTTP 406 (Not Acceptable). Clients would
receive an
explicit error instead of silent data loss. This path is currently
unreachable via the
REST API (all REST tables are external), so no existing clients are
affected.
### How was this patch tested?
- Unit test `testDeregisterTableRejectsManagedTable` mocks a managed
table (empty
properties) and verifies:
- `UnsupportedOperationException` is thrown
- `dropTable` is never called on the catalog
- Existing integration test `LanceRESTServiceIT` covers the external
table happy path
(deregister preserves physical data).
Signed-off-by: jiangxt2 <[email protected]>
---
.../gravitino/GravitinoLanceTableOperations.java | 16 +++++++++-
.../rest/TestGravitinoLanceTableOperations.java | 34 ++++++++++++++++++++++
2 files changed, 49 insertions(+), 1 deletion(-)
diff --git
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
index cad7735118..50ba09df30 100644
---
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
+++
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
@@ -281,7 +281,21 @@ public class GravitinoLanceTableOperations implements
LanceTableOperations {
"Table not found: " + tableId, CommonUtil.formatCurrentStackTrace(),
tableId);
}
Map<String, String> properties = t.properties();
- // TODO Support real deregister API.
+
+ // Verify the table is external before deregistering. For non-external
(managed) tables,
+ // dropTable would delete the underlying Lance data, violating the
deregister contract
+ // ("It will not delete the underlying lance data"). The TableCatalog
interface does not
+ // expose a metadata-only removal path, so we fail fast rather than risk
silent data loss.
+ boolean external =
+ Optional.ofNullable(properties.get(Table.PROPERTY_EXTERNAL))
+ .map(Boolean::parseBoolean)
+ .orElse(false);
+ if (!external) {
+ throw new UnsupportedOperationException(
+ "deregisterTable only supports external tables, but table " +
tableId + " is managed");
+ }
+
+ // External tables: dropTable removes catalog metadata only, preserving
Lance data.
boolean result = catalog.asTableCatalog().dropTable(tableIdentifier);
if (!result) {
throw new TableNotFoundException(
diff --git
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestGravitinoLanceTableOperations.java
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestGravitinoLanceTableOperations.java
index 10c9b6bf32..84618a0932 100644
---
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestGravitinoLanceTableOperations.java
+++
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/service/rest/TestGravitinoLanceTableOperations.java
@@ -21,11 +21,17 @@ package org.apache.gravitino.lance.service.rest;
import static
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_VERSION;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.NameIdentifier;
+import
org.apache.gravitino.lance.common.ops.gravitino.GravitinoLanceNamespaceWrapper;
import
org.apache.gravitino.lance.common.ops.gravitino.GravitinoLanceTableAlterHandler.AlterColumnsGravitinoLance;
import
org.apache.gravitino.lance.common.ops.gravitino.GravitinoLanceTableAlterHandler.DropColumns;
+import
org.apache.gravitino.lance.common.ops.gravitino.GravitinoLanceTableOperations;
import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableCatalog;
import org.apache.gravitino.rel.TableChange;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -92,4 +98,32 @@ class TestGravitinoLanceTableOperations {
Assertions.assertEquals(
"Only RENAME alteration is supported currently.",
exception.getMessage());
}
+
+ @Test
+ void testDeregisterTableRejectsManagedTable() {
+ // Mock a managed table (no PROPERTY_EXTERNAL=true)
+ Table managedTable = Mockito.mock(Table.class);
+ Mockito.when(managedTable.properties()).thenReturn(new HashMap<>());
+
+ TableCatalog tableCatalog = Mockito.mock(TableCatalog.class);
+ Mockito.when(tableCatalog.loadTable(Mockito.any(NameIdentifier.class)))
+ .thenReturn(managedTable);
+
+ Catalog catalog = Mockito.mock(Catalog.class);
+ Mockito.when(catalog.asTableCatalog()).thenReturn(tableCatalog);
+
+ GravitinoLanceNamespaceWrapper wrapper =
Mockito.mock(GravitinoLanceNamespaceWrapper.class);
+
Mockito.when(wrapper.loadAndValidateLakehouseCatalog(Mockito.anyString())).thenReturn(catalog);
+
+ GravitinoLanceTableOperations ops = new
GravitinoLanceTableOperations(wrapper);
+
+ UnsupportedOperationException exception =
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> ops.deregisterTable("catalog.schema.table", "."));
+ Assertions.assertTrue(exception.getMessage().contains("only supports
external tables"));
+
+ // Verify dropTable was never called — the guard must reject before
reaching the catalog layer.
+ Mockito.verify(tableCatalog, Mockito.never()).dropTable(Mockito.any());
+ }
}